Fix Download Cleaner making too many requests (#368)

This commit is contained in:
Flaminel
2025-12-10 09:22:51 +02:00
committed by GitHub
parent 02dff0bb9b
commit b343165644
134 changed files with 21645 additions and 2657 deletions

View File

@@ -158,7 +158,7 @@ jobs:
platforms: |
linux/amd64
linux/arm64
push: ${{ inputs.push_docker }}
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
tags: |
${{ env.githubTags }}
# Enable BuildKit cache for faster builds

View File

@@ -1,6 +1,7 @@
using System.Diagnostics;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Mvc;
@@ -14,18 +15,15 @@ public class StatusController : ControllerBase
{
private readonly ILogger<StatusController> _logger;
private readonly DataContext _dataContext;
private readonly DownloadServiceFactory _downloadServiceFactory;
private readonly ArrClientFactory _arrClientFactory;
private readonly IArrClientFactory _arrClientFactory;
public StatusController(
ILogger<StatusController> logger,
DataContext dataContext,
DownloadServiceFactory downloadServiceFactory,
ArrClientFactory arrClientFactory)
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_downloadServiceFactory = downloadServiceFactory;
_arrClientFactory = arrClientFactory;
}

View File

@@ -1,5 +1,7 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
@@ -10,7 +12,6 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
@@ -23,20 +24,18 @@ public static class ServicesDI
{
public static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddScoped<IEncryptionService, AesEncryptionService>()
.AddScoped<SensitiveDataJsonConverter>()
.AddScoped<EventsContext>()
.AddScoped<DataContext>()
.AddScoped<EventPublisher>()
.AddScoped<IEventPublisher, EventPublisher>()
.AddHostedService<EventCleanupService>()
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
.AddScoped<CertificateValidationService>()
.AddScoped<SonarrClient>()
.AddScoped<RadarrClient>()
.AddScoped<LidarrClient>()
.AddScoped<ReadarrClient>()
.AddScoped<WhisparrClient>()
.AddScoped<ArrClientFactory>()
.AddScoped<ISonarrClient, SonarrClient>()
.AddScoped<IRadarrClient, RadarrClient>()
.AddScoped<ILidarrClient, LidarrClient>()
.AddScoped<IReadarrClient, ReadarrClient>()
.AddScoped<IWhisparrClient, WhisparrClient>()
.AddScoped<IArrClientFactory, ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
@@ -47,15 +46,16 @@ public static class ServicesDI
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<UnixHardLinkFileService>()
.AddScoped<WindowsHardLinkFileService>()
.AddScoped<ArrQueueIterator>()
.AddScoped<DownloadServiceFactory>()
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddScoped<FileReader>()
.AddScoped<IRuleManager, RuleManager>()
.AddScoped<IRuleEvaluator, RuleEvaluator>()
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
.AddSingleton<IJobManagementService, JobManagementService>()
.AddSingleton<BlocklistProvider>()
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
.AddSingleton(TimeProvider.System)
.AddSingleton<AppStatusSnapshot>()
.AddHostedService<AppStatusRefreshService>();
}

View File

@@ -4,49 +4,34 @@ namespace Cleanuparr.Domain.Entities;
/// Universal abstraction for a torrent item across all download clients.
/// Provides a unified interface for accessing torrent properties and state.
/// </summary>
public interface ITorrentItem
public interface ITorrentItemWrapper
{
// Basic identification
string Hash { get; }
string Name { get; }
// Privacy and tracking
bool IsPrivate { get; }
IReadOnlyList<string> Trackers { get; }
// Size and progress
long Size { get; }
double CompletionPercentage { get; }
long DownloadedBytes { get; }
long TotalUploaded { get; }
// Speed and transfer rates
long DownloadSpeed { get; }
long UploadSpeed { get; }
double Ratio { get; }
// Time tracking
long Eta { get; }
DateTime? DateAdded { get; }
DateTime? DateCompleted { get; }
long SeedingTimeSeconds { get; }
// Categories and tags
string? Category { get; }
IReadOnlyList<string> Tags { get; }
string? Category { get; set; }
// State checking methods
bool IsDownloading();
bool IsStalled();
bool IsSeeding();
bool IsCompleted();
bool IsPaused();
bool IsQueued();
bool IsChecking();
bool IsAllocating();
bool IsMetadataDownloading();
// Filtering methods
/// <summary>
/// Determines if this torrent should be ignored based on the provided patterns.
/// Checks if any pattern matches the torrent name, hash, or tracker.

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -6,6 +6,10 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
</ItemGroup>
@@ -17,6 +21,7 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
@@ -26,6 +31,10 @@
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,130 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Events;
/// <summary>
/// Integration tests for the cleanup logic that actually deletes events
/// </summary>
public class EventCleanupServiceIntegrationTests : IDisposable
{
private readonly EventsContext _context;
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
private readonly IServiceProvider _serviceProvider;
private readonly string _dbName;
public EventCleanupServiceIntegrationTests()
{
_dbName = Guid.NewGuid().ToString();
var services = new ServiceCollection();
// Setup in-memory database
services.AddDbContext<EventsContext>(options =>
options.UseInMemoryDatabase(databaseName: _dbName));
_serviceProvider = services.BuildServiceProvider();
_loggerMock = new Mock<ILogger<EventCleanupService>>();
using var scope = _serviceProvider.CreateScope();
_context = scope.ServiceProvider.GetRequiredService<EventsContext>();
}
public void Dispose()
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
context.Database.EnsureDeleted();
}
[Fact]
public async Task CleanupService_PreservesRecentEvents()
{
// Arrange - Add recent events (within retention period)
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
context.Events.Add(new AppEvent
{
Id = Guid.NewGuid(),
EventType = EventType.QueueItemDeleted,
Message = "Recent event 1",
Severity = EventSeverity.Information,
Timestamp = DateTime.UtcNow.AddDays(-5)
});
context.Events.Add(new AppEvent
{
Id = Guid.NewGuid(),
EventType = EventType.DownloadCleaned,
Message = "Recent event 2",
Severity = EventSeverity.Important,
Timestamp = DateTime.UtcNow.AddDays(-10)
});
await context.SaveChangesAsync();
}
// Verify events exist
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
var count = await context.Events.CountAsync();
Assert.Equal(2, count);
}
}
[Fact]
public async Task EventCleanupService_CanStartAndStop()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act
cts.CancelAfter(100);
await service.StartAsync(cts.Token);
// Give some time for the service to process
await Task.Delay(150);
await service.StopAsync(CancellationToken.None);
// Assert - the service should complete without throwing
Assert.True(true);
}
[Fact]
public async Task EventCleanupService_HandlesExceptionsGracefully()
{
// Arrange
// 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(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act
cts.CancelAfter(100);
await service.StartAsync(cts.Token);
await Task.Delay(150);
await service.StopAsync(CancellationToken.None);
// Assert - the service should handle the error and continue (log it but not crash)
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to perform event cleanup")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
}

View File

@@ -0,0 +1,130 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Events;
public class EventCleanupServiceTests : IDisposable
{
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
private readonly ServiceCollection _services;
private readonly IServiceProvider _serviceProvider;
private readonly string _dbName;
public EventCleanupServiceTests()
{
_loggerMock = new Mock<ILogger<EventCleanupService>>();
_services = new ServiceCollection();
_dbName = Guid.NewGuid().ToString();
// Setup in-memory database for testing
_services.AddDbContext<EventsContext>(options =>
options.UseInMemoryDatabase(databaseName: _dbName));
_serviceProvider = _services.BuildServiceProvider();
}
public void Dispose()
{
// Cleanup the in-memory database
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
context.Database.EnsureDeleted();
}
[Fact]
public async Task ExecuteAsync_LogsStartMessage()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act - start and immediately cancel
cts.CancelAfter(100);
await service.StartAsync(cts.Token);
await Task.Delay(200); // Give it time to process
await service.StopAsync(CancellationToken.None);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("started")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task StopAsync_LogsStopMessage()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act
cts.CancelAfter(50);
await service.StartAsync(cts.Token);
await Task.Delay(100);
await service.StopAsync(CancellationToken.None);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopping")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public void Constructor_InitializesWithCorrectParameters()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
// Act
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
// Assert - service should be created without exception
Assert.NotNull(service);
}
[Fact]
public async Task ExecuteAsync_GracefullyHandlesCancellation()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act - cancel immediately
cts.Cancel();
// Start should not throw
await service.StartAsync(cts.Token);
await Task.Delay(50);
await service.StopAsync(CancellationToken.None);
// Assert - should have logged stopped message
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopped")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}

View File

@@ -0,0 +1,523 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Events;
public class EventPublisherTests : IDisposable
{
private readonly EventsContext _context;
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
private readonly Mock<ILogger<EventPublisher>> _loggerMock;
private readonly Mock<INotificationPublisher> _notificationPublisherMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<IClientProxy> _clientProxyMock;
private readonly EventPublisher _publisher;
public EventPublisherTests()
{
// Setup in-memory database
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new EventsContext(options);
// Setup mocks
_hubContextMock = new Mock<IHubContext<AppHub>>();
_loggerMock = new Mock<ILogger<EventPublisher>>();
_notificationPublisherMock = new Mock<INotificationPublisher>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_clientProxyMock = new Mock<IClientProxy>();
// Setup HubContext to return client proxy
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object);
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
// Setup dry run interceptor to execute the delegate
_dryRunInterceptorMock.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns<Delegate, object[]>(async (del, args) =>
{
if (del is Func<AppEvent, Task> func && args.Length > 0 && args[0] is AppEvent appEvent)
{
await func(appEvent);
}
else if (del is Func<ManualEvent, Task> manualFunc && args.Length > 0 && args[0] is ManualEvent manualEvent)
{
await manualFunc(manualEvent);
}
});
_publisher = new EventPublisher(
_context,
_hubContextMock.Object,
_loggerMock.Object,
_notificationPublisherMock.Object,
_dryRunInterceptorMock.Object);
}
public void Dispose()
{
_context.Database.EnsureDeleted();
_context.Dispose();
}
#region PublishAsync Tests
[Fact]
public async Task PublishAsync_SavesEventToDatabase()
{
// Arrange
var eventType = EventType.QueueItemDeleted;
var message = "Test message";
var severity = EventSeverity.Important;
// Act
await _publisher.PublishAsync(eventType, message, severity);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(eventType, savedEvent.EventType);
Assert.Equal(message, savedEvent.Message);
Assert.Equal(severity, savedEvent.Severity);
}
[Fact]
public async Task PublishAsync_WithData_SerializesDataToJson()
{
// Arrange
var eventType = EventType.DownloadCleaned;
var message = "Download cleaned";
var severity = EventSeverity.Information;
var data = new { Name = "TestDownload", Hash = "abc123" };
// Act
await _publisher.PublishAsync(eventType, message, severity, data);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.NotNull(savedEvent.Data);
Assert.Contains("TestDownload", savedEvent.Data);
Assert.Contains("abc123", savedEvent.Data);
}
[Fact]
public async Task PublishAsync_WithTrackingId_SavesTrackingId()
{
// Arrange
var eventType = EventType.StalledStrike;
var message = "Strike received";
var severity = EventSeverity.Warning;
var trackingId = Guid.NewGuid();
// Act
await _publisher.PublishAsync(eventType, message, severity, trackingId: trackingId);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(trackingId, savedEvent.TrackingId);
}
[Fact]
public async Task PublishAsync_NotifiesSignalRClients()
{
// Arrange
var eventType = EventType.CategoryChanged;
var message = "Category changed";
var severity = EventSeverity.Information;
// Act
await _publisher.PublishAsync(eventType, message, severity);
// Assert
_clientProxyMock.Verify(c => c.SendCoreAsync(
"EventReceived",
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task PublishAsync_WhenSignalRFails_LogsError()
{
// Arrange
var eventType = EventType.QueueItemDeleted;
var message = "Test message";
var severity = EventSeverity.Important;
_clientProxyMock.Setup(c => c.SendCoreAsync(
It.IsAny<string>(),
It.IsAny<object[]>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("SignalR connection failed"));
// Act - should not throw
await _publisher.PublishAsync(eventType, message, severity);
// Assert - verify event was still saved
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
}
[Fact]
public async Task PublishAsync_NullData_DoesNotSerialize()
{
// Arrange
var eventType = EventType.DownloadCleaned;
var message = "Test";
var severity = EventSeverity.Information;
// Act
await _publisher.PublishAsync(eventType, message, severity, data: null);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Null(savedEvent.Data);
}
#endregion
#region PublishManualAsync Tests
[Fact]
public async Task PublishManualAsync_SavesManualEventToDatabase()
{
// Arrange
var message = "Manual event message";
var severity = EventSeverity.Warning;
// Act
await _publisher.PublishManualAsync(message, severity);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(message, savedEvent.Message);
Assert.Equal(severity, savedEvent.Severity);
}
[Fact]
public async Task PublishManualAsync_WithData_SerializesDataToJson()
{
// Arrange
var message = "Manual event";
var severity = EventSeverity.Important;
var data = new { ItemName = "TestItem", Count = 5 };
// Act
await _publisher.PublishManualAsync(message, severity, data);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.NotNull(savedEvent.Data);
Assert.Contains("TestItem", savedEvent.Data);
Assert.Contains("5", savedEvent.Data);
}
[Fact]
public async Task PublishManualAsync_NotifiesSignalRClients()
{
// Arrange
var message = "Manual event";
var severity = EventSeverity.Information;
// Act
await _publisher.PublishManualAsync(message, severity);
// Assert
_clientProxyMock.Verify(c => c.SendCoreAsync(
"ManualEventReceived",
It.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
It.IsAny<CancellationToken>()), Times.Once);
}
#endregion
#region DryRun Interceptor Tests
[Fact]
public async Task PublishAsync_UsesDryRunInterceptor()
{
// Arrange
var eventType = EventType.StalledStrike;
var message = "Test";
var severity = EventSeverity.Warning;
// Act
await _publisher.PublishAsync(eventType, message, severity);
// Assert
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
It.IsAny<Delegate>(),
It.IsAny<object[]>()), Times.Once);
}
[Fact]
public async Task PublishManualAsync_UsesDryRunInterceptor()
{
// Arrange
var message = "Manual test";
var severity = EventSeverity.Important;
// Act
await _publisher.PublishManualAsync(message, severity);
// Assert
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
It.IsAny<Delegate>(),
It.IsAny<object[]>()), Times.Once);
}
#endregion
#region Data Serialization Tests
[Fact]
public async Task PublishAsync_SerializesEnumsAsStrings()
{
// Arrange
var eventType = EventType.QueueItemDeleted;
var message = "Test";
var severity = EventSeverity.Important;
var data = new { Reason = DeleteReason.Stalled };
// Act
await _publisher.PublishAsync(eventType, message, severity, data);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.NotNull(savedEvent.Data);
Assert.Contains("Stalled", savedEvent.Data);
}
[Fact]
public async Task PublishAsync_HandlesComplexData()
{
// Arrange
var eventType = EventType.DownloadCleaned;
var message = "Test";
var severity = EventSeverity.Information;
var data = new
{
Items = new[] { "item1", "item2" },
Nested = new { Value = 123 },
NullableValue = (string?)null
};
// Act
await _publisher.PublishAsync(eventType, message, severity, data);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.NotNull(savedEvent.Data);
Assert.Contains("item1", savedEvent.Data);
Assert.Contains("123", savedEvent.Data);
}
#endregion
#region PublishQueueItemDeleted Tests
[Fact]
public async Task PublishQueueItemDeleted_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set("downloadName", "Test Download");
ContextProvider.Set("hash", "abc123");
// Act
await _publisher.PublishQueueItemDeleted(removeFromClient: true, DeleteReason.Stalled);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(EventType.QueueItemDeleted, savedEvent.EventType);
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
Assert.NotNull(savedEvent.Data);
Assert.Contains("Test Download", savedEvent.Data);
Assert.Contains("abc123", savedEvent.Data);
Assert.Contains("Stalled", savedEvent.Data);
}
[Fact]
public async Task PublishQueueItemDeleted_SendsNotification()
{
// Arrange
ContextProvider.Set("downloadName", "Test Download");
ContextProvider.Set("hash", "abc123");
// Act
await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport);
// Assert
_notificationPublisherMock.Verify(n => n.NotifyQueueItemDeleted(false, DeleteReason.FailedImport), Times.Once);
}
#endregion
#region PublishDownloadCleaned Tests
[Fact]
public async Task PublishDownloadCleaned_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set("downloadName", "Cleaned Download");
ContextProvider.Set("hash", "def456");
// Act
await _publisher.PublishDownloadCleaned(
ratio: 2.5,
seedingTime: TimeSpan.FromHours(48),
categoryName: "movies",
reason: CleanReason.MaxSeedTimeReached);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(EventType.DownloadCleaned, savedEvent.EventType);
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
Assert.NotNull(savedEvent.Data);
Assert.Contains("Cleaned Download", savedEvent.Data);
Assert.Contains("def456", savedEvent.Data);
Assert.Contains("movies", savedEvent.Data);
Assert.Contains("MaxSeedTimeReached", savedEvent.Data);
}
[Fact]
public async Task PublishDownloadCleaned_SendsNotification()
{
// Arrange
ContextProvider.Set("downloadName", "Test");
ContextProvider.Set("hash", "xyz");
var ratio = 1.5;
var seedingTime = TimeSpan.FromHours(24);
var categoryName = "tv";
var reason = CleanReason.MaxRatioReached;
// Act
await _publisher.PublishDownloadCleaned(ratio, seedingTime, categoryName, reason);
// Assert
_notificationPublisherMock.Verify(n => n.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason), Times.Once);
}
#endregion
#region PublishSearchNotTriggered Tests
[Fact]
public async Task PublishSearchNotTriggered_SavesManualEvent()
{
// Arrange
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:8989"));
// Act
await _publisher.PublishSearchNotTriggered("abc123", "Test Item");
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(EventSeverity.Warning, savedEvent.Severity);
Assert.Contains("Replacement search was not triggered", savedEvent.Message);
Assert.NotNull(savedEvent.Data);
Assert.Contains("Test Item", savedEvent.Data);
Assert.Contains("abc123", savedEvent.Data);
}
#endregion
#region PublishRecurringItem Tests
[Fact]
public async Task PublishRecurringItem_SavesManualEvent()
{
// Arrange
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Radarr);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:7878"));
// Act
await _publisher.PublishRecurringItem("hash123", "Recurring Item", 5);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
Assert.Contains("keeps coming back", savedEvent.Message);
Assert.NotNull(savedEvent.Data);
Assert.Contains("Recurring Item", savedEvent.Data);
Assert.Contains("hash123", savedEvent.Data);
}
#endregion
#region PublishCategoryChanged Tests
[Fact]
public async Task PublishCategoryChanged_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set("downloadName", "Category Test");
ContextProvider.Set("hash", "cat123");
// Act
await _publisher.PublishCategoryChanged("oldCat", "newCat", isTag: false);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(EventType.CategoryChanged, savedEvent.EventType);
Assert.Equal(EventSeverity.Information, savedEvent.Severity);
Assert.Contains("Category changed from 'oldCat' to 'newCat'", savedEvent.Message);
}
[Fact]
public async Task PublishCategoryChanged_WithTag_SavesCorrectMessage()
{
// Arrange
ContextProvider.Set("downloadName", "Tag Test");
ContextProvider.Set("hash", "tag123");
// Act
await _publisher.PublishCategoryChanged("", "cleanuperr-done", isTag: true);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Contains("Tag 'cleanuperr-done' added", savedEvent.Message);
}
[Fact]
public async Task PublishCategoryChanged_SendsNotification()
{
// Arrange
ContextProvider.Set("downloadName", "Test");
ContextProvider.Set("hash", "xyz");
// Act
await _publisher.PublishCategoryChanged("old", "new", isTag: true);
// Assert
_notificationPublisherMock.Verify(n => n.NotifyCategoryChanged("old", "new", true), Times.Once);
}
#endregion
}

View File

@@ -0,0 +1,132 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
public class ArrClientFactoryTests
{
private readonly Mock<ISonarrClient> _sonarrClientMock;
private readonly Mock<IRadarrClient> _radarrClientMock;
private readonly Mock<ILidarrClient> _lidarrClientMock;
private readonly Mock<IReadarrClient> _readarrClientMock;
private readonly Mock<IWhisparrClient> _whisparrClientMock;
private readonly ArrClientFactory _factory;
public ArrClientFactoryTests()
{
_sonarrClientMock = new Mock<ISonarrClient>();
_radarrClientMock = new Mock<IRadarrClient>();
_lidarrClientMock = new Mock<ILidarrClient>();
_readarrClientMock = new Mock<IReadarrClient>();
_whisparrClientMock = new Mock<IWhisparrClient>();
_factory = new ArrClientFactory(
_sonarrClientMock.Object,
_radarrClientMock.Object,
_lidarrClientMock.Object,
_readarrClientMock.Object,
_whisparrClientMock.Object
);
}
#region GetClient Tests
[Fact]
public void GetClient_Sonarr_ReturnsSonarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Sonarr);
// Assert
Assert.Same(_sonarrClientMock.Object, result);
}
[Fact]
public void GetClient_Radarr_ReturnsRadarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Radarr);
// Assert
Assert.Same(_radarrClientMock.Object, result);
}
[Fact]
public void GetClient_Lidarr_ReturnsLidarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Lidarr);
// Assert
Assert.Same(_lidarrClientMock.Object, result);
}
[Fact]
public void GetClient_Readarr_ReturnsReadarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Readarr);
// Assert
Assert.Same(_readarrClientMock.Object, result);
}
[Fact]
public void GetClient_Whisparr_ReturnsWhisparrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Whisparr);
// Assert
Assert.Same(_whisparrClientMock.Object, result);
}
[Fact]
public void GetClient_UnsupportedType_ThrowsNotImplementedException()
{
// Arrange
var unsupportedType = (InstanceType)999;
// Act & Assert
var exception = Assert.Throws<NotImplementedException>(() => _factory.GetClient(unsupportedType));
Assert.Contains("not yet supported", exception.Message);
Assert.Contains("999", exception.Message);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public void GetClient_AllSupportedTypes_ReturnsNonNullClient(InstanceType instanceType)
{
// Act
var result = _factory.GetClient(instanceType);
// Assert
Assert.NotNull(result);
Assert.IsAssignableFrom<IArrClient>(result);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public void GetClient_CalledMultipleTimes_ReturnsSameInstance(InstanceType instanceType)
{
// Act
var result1 = _factory.GetClient(instanceType);
var result2 = _factory.GetClient(instanceType);
// Assert
Assert.Same(result1, result2);
}
#endregion
}

View File

@@ -0,0 +1,365 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Persistence.Models.State;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using System.Net;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.BlacklistSync;
public class BlacklistSynchronizerTests : IDisposable
{
private readonly Mock<ILogger<BlacklistSynchronizer>> _loggerMock;
private readonly DataContext _dataContext;
private readonly Mock<IDownloadServiceFactory> _downloadServiceFactoryMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly FileReader _fileReader;
private readonly BlacklistSynchronizer _synchronizer;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly SqliteConnection _connection;
public BlacklistSynchronizerTests()
{
_loggerMock = new Mock<ILogger<BlacklistSynchronizer>>();
// Use SQLite in-memory with shared connection to support complex types
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(_connection)
.Options;
_dataContext = new DataContext(options);
_dataContext.Database.EnsureCreated();
_downloadServiceFactoryMock = new Mock<IDownloadServiceFactory>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
// Setup interceptor to execute the action with params using DynamicInvoke
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
var result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
});
// Setup mock HTTP handler for FileReader
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
var httpClientFactoryMock = new Mock<IHttpClientFactory>();
httpClientFactoryMock
.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(httpClient);
_fileReader = new FileReader(httpClientFactoryMock.Object);
_synchronizer = new BlacklistSynchronizer(
_loggerMock.Object,
_dataContext,
_downloadServiceFactoryMock.Object,
_fileReader,
_dryRunInterceptorMock.Object
);
}
public void Dispose()
{
_dataContext.Dispose();
_connection.Dispose();
}
#region ExecuteAsync - Disabled Tests
[Fact]
public async Task ExecuteAsync_WhenDisabled_ReturnsEarlyWithoutProcessing()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: false);
// Act
await _synchronizer.ExecuteAsync();
// Assert
_downloadServiceFactoryMock.Verify(
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
Times.Never);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region ExecuteAsync - Path Not Configured Tests
[Fact]
public async Task ExecuteAsync_WhenPathNotConfigured_LogsWarningAndReturns()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: null);
// Act
await _synchronizer.ExecuteAsync();
// Assert
_downloadServiceFactoryMock.Verify(
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
Times.Never);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("path is not configured")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_WhenPathIsWhitespace_LogsWarningAndReturns()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: " ");
// Act
await _synchronizer.ExecuteAsync();
// Assert
_downloadServiceFactoryMock.Verify(
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
Times.Never);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("path is not configured")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region ExecuteAsync - No Clients Tests
[Fact]
public async Task ExecuteAsync_WhenNoQBittorrentClients_LogsDebugAndReturns()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse("pattern1\npattern2");
// Don't add any download clients
// Act
await _synchronizer.ExecuteAsync();
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_WhenOnlyDelugeClients_LogsDebugAndReturns()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse("pattern1\npattern2");
// Add only a Deluge client
await AddDownloadClient(DownloadClientTypeName.Deluge, enabled: true);
// Act
await _synchronizer.ExecuteAsync();
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_WhenDisabledQBittorrentClient_DoesNotProcess()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse("pattern1\npattern2");
// Add a disabled qBittorrent client
await AddDownloadClient(DownloadClientTypeName.qBittorrent, enabled: false);
// Act
await _synchronizer.ExecuteAsync();
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region ExecuteAsync - Already Synced Tests
[Fact]
public async Task ExecuteAsync_WhenClientAlreadySynced_SkipsClient()
{
// Arrange
var patterns = "pattern1\npattern2";
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse(patterns);
var clientId = await AddDownloadClient(DownloadClientTypeName.qBittorrent, enabled: true);
// Calculate the expected hash (same as ComputeHash in BlacklistSynchronizer)
var cleanPatterns = string.Join('\n', patterns.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Where(p => !string.IsNullOrWhiteSpace(p)));
var hash = ComputeHash(cleanPatterns);
// Add sync history for this client with the same hash
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
{
Hash = hash,
DownloadClientId = clientId
});
await _dataContext.SaveChangesAsync();
// Act
await _synchronizer.ExecuteAsync();
// Assert
_downloadServiceFactoryMock.Verify(
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
Times.Never);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("already synced")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region ExecuteAsync - Dry Run Tests
[Fact]
public async Task ExecuteAsync_UsesDryRunInterceptor()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse("pattern1\npattern2");
// Act
await _synchronizer.ExecuteAsync();
// Assert - Verify interceptor was called (with Delegate, not Func<object, object, Task>)
_dryRunInterceptorMock.Verify(
d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()),
Times.AtLeastOnce);
}
#endregion
#region Helper Methods
private async Task SetupBlacklistSyncConfig(bool enabled, string? blacklistPath = null)
{
var config = new BlacklistSyncConfig
{
Enabled = enabled,
BlacklistPath = blacklistPath
};
_dataContext.BlacklistSyncConfigs.Add(config);
await _dataContext.SaveChangesAsync();
}
private async Task<Guid> AddDownloadClient(DownloadClientTypeName typeName, bool enabled)
{
var client = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = $"Test {typeName} Client",
TypeName = typeName,
Type = DownloadClientType.Torrent,
Host = new Uri("http://test.example.com"),
Enabled = enabled
};
_dataContext.DownloadClients.Add(client);
await _dataContext.SaveChangesAsync();
return client.Id;
}
private void SetupHttpResponse(string content)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(content)
});
}
private static string ComputeHash(string content)
{
using var sha = System.Security.Cryptography.SHA256.Create();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
byte[] hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
#endregion
}

View File

@@ -1,269 +0,0 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeItemTests
{
[Fact]
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DelugeItem(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var downloadStatus = new DownloadStatus
{
Hash = expectedHash,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var downloadStatus = new DownloadStatus
{
Name = expectedName,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Name = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void IsPrivate_ReturnsCorrectValue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Private = true,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBeTrue();
}
[Fact]
public void Size_ReturnsCorrectValue()
{
// Arrange
var expectedSize = 1024L * 1024 * 1024; // 1GB
var downloadStatus = new DownloadStatus
{
Size = expectedSize,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expectedSize);
}
[Theory]
[InlineData(0, 1024, 0.0)]
[InlineData(512, 1024, 50.0)]
[InlineData(768, 1024, 75.0)]
[InlineData(1024, 1024, 100.0)]
[InlineData(0, 0, 0.0)] // Edge case: zero size
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
{
// Arrange
var downloadStatus = new DownloadStatus
{
TotalDone = totalDone,
Size = size,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker2.example.com/announce" },
new() { Url = "udp://tracker3.example.com:1337/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker1.example.com/announce" },
new() { Url = "udp://tracker1.example.com:1337/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://valid.example.com/announce" },
new() { Url = "invalid-url" },
new() { Url = "" },
new() { Url = null! }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Trackers_WithNullTrackers_ReturnsEmptyList()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = null!,
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,453 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeItemWrapperTests
{
[Fact]
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DelugeItemWrapper(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var downloadStatus = new DownloadStatus
{
Hash = expectedHash,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var downloadStatus = new DownloadStatus
{
Name = expectedName,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Name = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void IsPrivate_ReturnsCorrectValue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Private = true,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBeTrue();
}
[Fact]
public void Size_ReturnsCorrectValue()
{
// Arrange
var expectedSize = 1024L * 1024 * 1024; // 1GB
var downloadStatus = new DownloadStatus
{
Size = expectedSize,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expectedSize);
}
[Theory]
[InlineData(0, 1024, 0.0)]
[InlineData(512, 1024, 50.0)]
[InlineData(768, 1024, 75.0)]
[InlineData(1024, 1024, 100.0)]
[InlineData(0, 0, 0.0)] // Edge case: zero size
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
{
// Arrange
var downloadStatus = new DownloadStatus
{
TotalDone = totalDone,
Size = size,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Theory]
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
[InlineData(0L, 0L)]
public void DownloadedBytes_ReturnsCorrectValue(long totalDone, long expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
TotalDone = totalDone,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.DownloadedBytes;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(2.0f, 2.0)]
[InlineData(0.5f, 0.5)]
[InlineData(1.0f, 1.0)]
[InlineData(0.0f, 0.0)]
public void Ratio_ReturnsCorrectValue(float ratio, double expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
Ratio = ratio,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Ratio;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(3600UL, 3600L)] // 1 hour
[InlineData(0UL, 0L)]
[InlineData(86400UL, 86400L)] // 1 day
public void Eta_ReturnsCorrectValue(ulong eta, long expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
Eta = eta,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(86400L, 86400L)] // 1 day
[InlineData(0L, 0L)]
[InlineData(3600L, 3600L)] // 1 hour
public void SeedingTimeSeconds_ReturnsCorrectValue(long seedingTime, long expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
SeedingTime = seedingTime,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.SeedingTimeSeconds;
// Assert
result.ShouldBe(expected);
}
[Fact]
public void IsIgnored_WithEmptyList_ReturnsFalse()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.IsIgnored(Array.Empty<string>());
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsIgnored_MatchingHash_ReturnsTrue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
var ignoredDownloads = new[] { "abc123" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingCategory_ReturnsTrue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Label = "test-category",
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
var ignoredDownloads = new[] { "test-category" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Trackers = new List<Tracker>
{
new() { Url = "http://tracker.example.com/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Label = "some-category",
Trackers = new List<Tracker>
{
new() { Url = "http://tracker.example.com/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
var ignoredDownloads = new[] { "notmatching" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeFalse();
}
[Theory]
[InlineData(1024L * 1024, 1024L * 1024)] // 1MB/s
[InlineData(0L, 0L)]
[InlineData(500L, 500L)]
public void DownloadSpeed_ReturnsCorrectValue(long downloadSpeed, long expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
DownloadSpeed = downloadSpeed,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.DownloadSpeed;
// Assert
result.ShouldBe(expected);
}
[Fact]
public void Category_Setter_SetsLabel()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Label = "original-category",
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
wrapper.Category = "new-category";
// Assert
wrapper.Category.ShouldBe("new-category");
downloadStatus.Label.ShouldBe("new-category");
}
[Theory]
[InlineData("Downloading", true)]
[InlineData("downloading", true)]
[InlineData("DOWNLOADING", true)]
[InlineData("Seeding", false)]
[InlineData("Paused", false)]
[InlineData(null, false)]
public void IsDownloading_ReturnsCorrectValue(string? state, bool expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
State = state,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.IsDownloading();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData("Downloading", 0, 0UL, true)] // Downloading with no speed and no ETA = stalled
[InlineData("Downloading", 1000, 0UL, false)] // Has download speed = not stalled
[InlineData("Downloading", 0, 100UL, false)] // Has ETA = not stalled
[InlineData("Downloading", 1000, 100UL, false)] // Has both = not stalled
[InlineData("Seeding", 0, 0UL, false)] // Not downloading state = not stalled
[InlineData("Paused", 0, 0UL, false)] // Not downloading state = not stalled
[InlineData(null, 0, 0UL, false)] // Null state = not stalled
public void IsStalled_ReturnsCorrectValue(string? state, long downloadSpeed, ulong eta, bool expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
State = state,
DownloadSpeed = downloadSpeed,
Eta = eta,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.IsStalled();
// Assert
result.ShouldBe(expected);
}
}

View File

@@ -0,0 +1,772 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
{
private readonly DelugeServiceFixture _fixture;
public DelugeServiceDCTests(DelugeServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class GetSeedingDownloads_Tests : DelugeServiceDCTests
{
public GetSeedingDownloads_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FiltersSeedingState()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "Downloading", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, item => Assert.NotNull(item.Hash));
}
[Fact]
public async Task IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "SEEDING", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Equal(2, result.Count);
}
[Fact]
public async Task ReturnsEmptyList_WhenNull()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync((List<DownloadStatus>?)null);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SkipsTorrentsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "", Name = "No Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : DelugeServiceDCTests
{
public FilterDownloadsToBeCleanedAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, x => x.Category == "movies");
Assert.Contains(result, x => x.Category == "tv");
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void ReturnsEmptyList_WhenNoMatches()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class FilterDownloadsToChangeCategoryAsync_Tests : DelugeServiceDCTests
{
public FilterDownloadsToChangeCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void FiltersCorrectly()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void SkipsDownloadsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class CreateCategoryAsync_Tests : DelugeServiceDCTests
{
public CreateCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CreatesLabel_WhenMissing()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.CreateLabel("new-label"))
.Returns(Task.CompletedTask);
// Act
await sut.CreateCategoryAsync("new-label");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel("new-label"), Times.Once);
}
[Fact]
public async Task SkipsCreation_WhenLabelExists()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string> { "existing" });
// Act
await sut.CreateCategoryAsync("existing");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string> { "Existing" });
// Act
await sut.CreateCategoryAsync("existing");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
}
}
public class DeleteDownload_Tests : DelugeServiceDCTests
{
public DeleteDownload_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CallsClientDelete()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash"))))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash"))),
Times.Once);
}
[Fact]
public async Task NormalizesHashToLowercase()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>()))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("uppercase-hash"))),
Times.Once);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : DelugeServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NullDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task EmptyDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingHash_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingName_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingCategory_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ExceptionGettingFiles_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task NoHardlinks_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabel("hash1", "unlinked"),
Times.Once);
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task FileNotFound_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SkippedFiles_IgnoredInCheck()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "file1.mkv" } },
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "file2.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
}
[Fact]
public async Task WithIgnoredRootDir_PopulatesFileCounts()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
Times.Once);
}
[Fact]
public async Task PublishesCategoryChangedEvent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabel("hash1", "unlinked"),
Times.Once);
}
}
}

View File

@@ -0,0 +1,118 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceFixture : IDisposable
{
public Mock<ILogger<DelugeService>> Logger { get; }
public MemoryCache Cache { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public BlocklistProvider BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IDelugeClientWrapper> ClientWrapper { get; }
public DelugeServiceFixture()
{
Logger = new Mock<ILogger<DelugeService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = TestBlocklistProviderFactory.Create();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IDelugeClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public DelugeService CreateSut(DownloadClientConfig? config = null)
{
config ??= new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Test Client",
TypeName = Domain.Enums.DownloadClientTypeName.Deluge,
Type = Domain.Enums.DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost:8112"),
Username = "admin",
Password = "admin",
UrlBase = ""
};
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.Returns(httpClient);
return new DelugeService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,499 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
private readonly DelugeServiceFixture _fixture;
public DelugeServiceTests(DelugeServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_BasicScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentNotFound_ReturnsEmptyResult()
{
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync((DownloadStatus?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = true,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.True(result.IsPrivate);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.False(result.IsPrivate);
}
}
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task AllFilesUnwanted_DeletesFromClient()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0 } },
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 1 } }
}
});
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task SomeFilesWanted_DoesNotRemove()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0 } },
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string category = "test-category";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Label = category,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByTrackerDomain_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string trackerDomain = "tracker.example.com";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>
{
new Tracker { Url = $"https://{trackerDomain}/announce" }
},
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NotDownloadingState_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Seeding",
Private = false,
DownloadSpeed = 0,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
}
[Fact]
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 0,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
}
}
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
DownloadSpeed = 0,
Eta = 0,
Private = false,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
}

View File

@@ -0,0 +1,281 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DownloadServiceFactoryTests : IDisposable
{
private readonly Mock<ILogger<DownloadServiceFactory>> _loggerMock;
private readonly IServiceProvider _serviceProvider;
private readonly DownloadServiceFactory _factory;
private readonly MemoryCache _memoryCache;
public DownloadServiceFactoryTests()
{
_loggerMock = new Mock<ILogger<DownloadServiceFactory>>();
var services = new ServiceCollection();
// Use real MemoryCache - mocks don't work properly with cache operations
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
services.AddSingleton<IMemoryCache>(_memoryCache);
// Register loggers
services.AddSingleton(Mock.Of<ILogger<QBitService>>());
services.AddSingleton(Mock.Of<ILogger<DelugeService>>());
services.AddSingleton(Mock.Of<ILogger<TransmissionService>>());
services.AddSingleton(Mock.Of<ILogger<UTorrentService>>());
services.AddSingleton(Mock.Of<IFilenameEvaluator>());
services.AddSingleton(Mock.Of<IStriker>());
services.AddSingleton(Mock.Of<IDryRunInterceptor>());
services.AddSingleton(Mock.Of<IHardLinkFileService>());
// IDynamicHttpClientProvider must return a real HttpClient for download services
var httpClientProviderMock = new Mock<IDynamicHttpClientProvider>();
httpClientProviderMock.Setup(p => p.CreateClient(It.IsAny<DownloadClientConfig>())).Returns(new HttpClient());
services.AddSingleton(httpClientProviderMock.Object);
services.AddSingleton(Mock.Of<IRuleEvaluator>());
services.AddSingleton(Mock.Of<IRuleManager>());
// UTorrentService needs ILoggerFactory
services.AddLogging();
// EventPublisher requires specific constructor arguments
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var eventsContext = new EventsContext(eventsContextOptions);
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
services.AddSingleton<IEventPublisher>(new EventPublisher(
eventsContext,
hubContextMock.Object,
Mock.Of<ILogger<EventPublisher>>(),
Mock.Of<INotificationPublisher>(),
Mock.Of<IDryRunInterceptor>()));
// BlocklistProvider requires specific constructor arguments
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
services.AddSingleton(new BlocklistProvider(
Mock.Of<ILogger<BlocklistProvider>>(),
scopeFactoryMock.Object,
_memoryCache));
_serviceProvider = services.BuildServiceProvider();
_factory = new DownloadServiceFactory(_loggerMock.Object, _serviceProvider);
}
public void Dispose()
{
_memoryCache.Dispose();
}
#region GetDownloadService Tests
[Fact]
public void GetDownloadService_QBittorrent_ReturnsQBitService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<QBitService>(service);
}
[Fact]
public void GetDownloadService_Deluge_ReturnsDelugeService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.Deluge);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<DelugeService>(service);
}
[Fact]
public void GetDownloadService_Transmission_ReturnsTransmissionService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.Transmission);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<TransmissionService>(service);
}
[Fact]
public void GetDownloadService_UTorrent_ReturnsUTorrentService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.uTorrent);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<UTorrentService>(service);
}
[Fact]
public void GetDownloadService_UnsupportedType_ThrowsNotSupportedException()
{
// Arrange
var config = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Unsupported Client",
TypeName = (DownloadClientTypeName)999, // Invalid type
Type = DownloadClientType.Torrent,
Host = new Uri("http://test.example.com"),
Enabled = true
};
// Act & Assert
var exception = Assert.Throws<NotSupportedException>(() => _factory.GetDownloadService(config));
Assert.Contains("not supported", exception.Message);
}
[Fact]
public void GetDownloadService_DisabledClient_LogsWarningButReturnsService()
{
// Arrange
var config = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Disabled qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://test.example.com"),
Enabled = false
};
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public void GetDownloadService_EnabledClient_DoesNotLogWarning()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Never);
}
[Theory]
[InlineData(DownloadClientTypeName.qBittorrent, typeof(QBitService))]
[InlineData(DownloadClientTypeName.Deluge, typeof(DelugeService))]
[InlineData(DownloadClientTypeName.Transmission, typeof(TransmissionService))]
[InlineData(DownloadClientTypeName.uTorrent, typeof(UTorrentService))]
public void GetDownloadService_AllSupportedTypes_ReturnCorrectServiceType(
DownloadClientTypeName typeName, Type expectedServiceType)
{
// Arrange
var config = CreateClientConfig(typeName);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType(expectedServiceType, service);
}
[Fact]
public void GetDownloadService_ReturnsNewInstanceEachTime()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
// Act
var service1 = _factory.GetDownloadService(config);
var service2 = _factory.GetDownloadService(config);
// Assert
Assert.NotSame(service1, service2);
}
#endregion
#region Helper Methods
private static DownloadClientConfig CreateClientConfig(DownloadClientTypeName typeName)
{
return new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = $"Test {typeName} Client",
TypeName = typeName,
Type = DownloadClientType.Torrent,
Host = new Uri("http://test.example.com"),
Enabled = true
};
}
#endregion
}

View File

@@ -5,7 +5,7 @@ using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class QBitItemTests
public class QBitItemWrapperTests
{
[Fact]
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
@@ -14,7 +14,7 @@ public class QBitItemTests
var trackers = new List<TorrentTracker>();
// Act & Assert
Should.Throw<ArgumentNullException>(() => new QBitItem(null!, trackers, false));
Should.Throw<ArgumentNullException>(() => new QBitItemWrapper(null!, trackers, false));
}
[Fact]
@@ -24,7 +24,7 @@ public class QBitItemTests
var torrentInfo = new TorrentInfo();
// Act & Assert
Should.Throw<ArgumentNullException>(() => new QBitItem(torrentInfo, null!, false));
Should.Throw<ArgumentNullException>(() => new QBitItemWrapper(torrentInfo, null!, false));
}
[Fact]
@@ -34,7 +34,7 @@ public class QBitItemTests
var expectedHash = "test-hash-123";
var torrentInfo = new TorrentInfo { Hash = expectedHash };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Hash;
@@ -49,7 +49,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Hash = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Hash;
@@ -65,7 +65,7 @@ public class QBitItemTests
var expectedName = "Test Torrent";
var torrentInfo = new TorrentInfo { Name = expectedName };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Name;
@@ -80,7 +80,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Name = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Name;
@@ -95,7 +95,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, true);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, true);
// Act
var result = wrapper.IsPrivate;
@@ -111,7 +111,7 @@ public class QBitItemTests
var expectedSize = 1024L * 1024 * 1024; // 1GB
var torrentInfo = new TorrentInfo { Size = expectedSize };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Size;
@@ -126,7 +126,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Size = 0 };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Size;
@@ -145,7 +145,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Progress = progress };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.CompletionPercentage;
@@ -155,86 +155,210 @@ public class QBitItemTests
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
public void DownloadedBytes_ReturnsCorrectValue()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker2.example.com/announce" },
new() { Url = "udp://tracker3.example.com:1337/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker1.example.com/announce" },
new() { Url = "udp://tracker1.example.com:1337/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://valid.example.com/announce" },
new() { Url = "invalid-url" },
new() { Url = "" },
new() { Url = null }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo();
var expectedDownloaded = 1024L * 1024 * 500; // 500MB
var torrentInfo = new TorrentInfo { Downloaded = expectedDownloaded };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
var result = wrapper.DownloadedBytes;
// Assert
result.ShouldBe(expectedDownloaded);
}
[Fact]
public void DownloadedBytes_WithNullValue_ReturnsZero()
{
// Arrange
var torrentInfo = new TorrentInfo { Downloaded = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.DownloadedBytes;
// Assert
result.ShouldBe(0);
}
[Fact]
public void DownloadSpeed_ReturnsCorrectValue()
{
// Arrange
var expectedSpeed = 1024 * 512; // 512 KB/s
var torrentInfo = new TorrentInfo { DownloadSpeed = expectedSpeed };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.DownloadSpeed;
// Assert
result.ShouldBe(expectedSpeed);
}
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
[InlineData(1.0)]
[InlineData(2.5)]
public void Ratio_ReturnsCorrectValue(double expectedRatio)
{
// Arrange
var torrentInfo = new TorrentInfo { Ratio = expectedRatio };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Ratio;
// Assert
result.ShouldBe(expectedRatio);
}
[Fact]
public void Eta_ReturnsCorrectValue()
{
// Arrange
var expectedEta = TimeSpan.FromMinutes(30);
var torrentInfo = new TorrentInfo { EstimatedTime = expectedEta };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe((long)expectedEta.TotalSeconds);
}
[Fact]
public void Eta_WithNullValue_ReturnsZero()
{
// Arrange
var torrentInfo = new TorrentInfo { EstimatedTime = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe(0);
}
[Fact]
public void SeedingTimeSeconds_ReturnsCorrectValue()
{
// Arrange
var expectedTime = TimeSpan.FromHours(5);
var torrentInfo = new TorrentInfo { SeedingTime = expectedTime };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.SeedingTimeSeconds;
// Assert
result.ShouldBe((long)expectedTime.TotalSeconds);
}
[Fact]
public void SeedingTimeSeconds_WithNullValue_ReturnsZero()
{
// Arrange
var torrentInfo = new TorrentInfo { SeedingTime = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.SeedingTimeSeconds;
// Assert
result.ShouldBe(0);
}
[Fact]
public void Tags_ReturnsCorrectValue()
{
// Arrange
var expectedTags = new List<string> { "tag1", "tag2", "tag3" };
var torrentInfo = new TorrentInfo { Tags = expectedTags.AsReadOnly() };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Tags;
// Assert
result.ShouldBe(expectedTags);
}
[Fact]
public void Tags_WithNullValue_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo { Tags = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Tags;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Tags_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo { Tags = new List<string>().AsReadOnly() };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Tags;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Category_ReturnsCorrectValue()
{
// Arrange
var expectedCategory = "movies";
var torrentInfo = new TorrentInfo { Category = expectedCategory };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Category;
// Assert
result.ShouldBe(expectedCategory);
}
[Fact]
public void Category_WithNullValue_ReturnsNull()
{
// Arrange
var torrentInfo = new TorrentInfo { Category = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Category;
// Assert
result.ShouldBeNull();
}
// State checking method tests
[Theory]
[InlineData(TorrentState.Downloading, true)]
@@ -247,7 +371,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsDownloading();
@@ -266,7 +390,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsStalled();
@@ -286,7 +410,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsSeeding();
@@ -295,101 +419,6 @@ public class QBitItemTests
result.ShouldBe(expected);
}
[Theory]
[InlineData(0.0, false)]
[InlineData(0.5, false)]
[InlineData(0.99, false)]
[InlineData(1.0, true)]
public void IsCompleted_ReturnsCorrectValue(double progress, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { Progress = progress };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsCompleted();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.PausedDownload, true)]
[InlineData(TorrentState.PausedUpload, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsPaused_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsPaused();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.QueuedDownload, true)]
[InlineData(TorrentState.QueuedUpload, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsQueued_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsQueued();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.CheckingDownload, true)]
[InlineData(TorrentState.CheckingUpload, true)]
[InlineData(TorrentState.CheckingResumeData, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsChecking_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsChecking();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.Allocating, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsAllocating_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsAllocating();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.FetchingMetadata, true)]
[InlineData(TorrentState.ForcedFetchingMetadata, true)]
@@ -400,7 +429,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsMetadataDownloading();
@@ -415,7 +444,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsIgnored(Array.Empty<string>());
@@ -430,7 +459,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "abc123" };
// Act
@@ -440,16 +469,62 @@ public class QBitItemTests
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTag_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123",
Tags = new List<string> { "test-tag" }.AsReadOnly()
};
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "test-tag" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingCategory_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123",
Category = "test-category"
};
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "test-category" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123"
};
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
@@ -463,12 +538,18 @@ public class QBitItemTests
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123",
Category = "some-category",
Tags = new List<string> { "some-tag" }.AsReadOnly()
};
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "notmatching" };
// Act

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class QBitServiceFixture : IDisposable
{
public Mock<ILogger<QBitService>> Logger { get; }
public MemoryCache Cache { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public BlocklistProvider BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IQBittorrentClientWrapper> ClientWrapper { get; }
public QBitServiceFixture()
{
Logger = new Mock<ILogger<QBitService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = TestBlocklistProviderFactory.Create();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IQBittorrentClientWrapper>();
// Setup default behavior for DryRunInterceptor to execute actions directly
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public QBitService CreateSut(DownloadClientConfig? config = null)
{
config ??= new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Test Client",
TypeName = Domain.Enums.DownloadClientTypeName.qBittorrent,
Type = Domain.Enums.DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "admin",
UrlBase = ""
};
// Setup HTTP client provider
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.Returns(httpClient);
return new QBitService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
// Re-setup default DryRunInterceptor behavior
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
/// <summary>
/// Test implementation of BlocklistProvider for testing purposes
/// </summary>
public static class TestBlocklistProviderFactory
{
public static BlocklistProvider Create()
{
var logger = new Mock<ILogger<BlocklistProvider>>().Object;
var scopeFactory = new Mock<IServiceScopeFactory>().Object;
var cache = new MemoryCache(new MemoryCacheOptions());
return new BlocklistProvider(logger, scopeFactory, cache);
}
}

View File

@@ -1,239 +0,0 @@
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Shouldly;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionItemTests
{
[Fact]
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new TransmissionItem(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var torrentInfo = new TorrentInfo { HashString = expectedHash };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = null };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var torrentInfo = new TorrentInfo { Name = expectedName };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = null };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
[InlineData(null, false)]
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(0L, 1024L, 0.0)]
[InlineData(512L, 1024L, 50.0)]
[InlineData(768L, 1024L, 75.0)]
[InlineData(1024L, 1024L, 100.0)]
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
[InlineData(512L, null, 0.0)] // Edge case: null total size
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
{
// Arrange
var torrentInfo = new TorrentInfo
{
DownloadedEver = downloadedEver,
TotalSize = totalSize
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker1.example.com:8080/announce" },
new() { Announce = "https://tracker2.example.com/announce" },
new() { Announce = "udp://tracker3.example.com:1337/announce" }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker1.example.com:8080/announce" },
new() { Announce = "https://tracker1.example.com/announce" },
new() { Announce = "udp://tracker1.example.com:1337/announce" }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://valid.example.com/announce" },
new() { Announce = "invalid-url" },
new() { Announce = "" },
new() { Announce = null }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[0]
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Trackers_WithNullTrackers_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = null
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,307 @@
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Shouldly;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionItemWrapperTests
{
[Fact]
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new TransmissionItemWrapper(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var torrentInfo = new TorrentInfo { HashString = expectedHash };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = null };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var torrentInfo = new TorrentInfo { Name = expectedName };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = null };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
[InlineData(null, false)]
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(0L, 1024L, 0.0)]
[InlineData(512L, 1024L, 50.0)]
[InlineData(768L, 1024L, 75.0)]
[InlineData(1024L, 1024L, 100.0)]
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
[InlineData(512L, null, 0.0)] // Edge case: null total size
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
{
// Arrange
var torrentInfo = new TorrentInfo
{
DownloadedEver = downloadedEver,
TotalSize = totalSize
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Theory]
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void DownloadedBytes_ReturnsCorrectValue(long? downloadedEver, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { DownloadedEver = downloadedEver };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.DownloadedBytes;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(1024L, 512L, 2.0)] // Uploaded more than downloaded
[InlineData(512L, 1024L, 0.5)] // Uploaded less than downloaded
[InlineData(1024L, 1024L, 1.0)] // Equal
[InlineData(0L, 1024L, 0.0)] // No upload
[InlineData(1024L, 0L, 0.0)] // No download
[InlineData(null, 1024L, 0.0)] // Null upload
[InlineData(1024L, null, 0.0)] // Null download
[InlineData(null, null, 0.0)] // Both null
public void Ratio_ReturnsCorrectValue(long? uploadedEver, long? downloadedEver, double expected)
{
// Arrange
var torrentInfo = new TorrentInfo
{
UploadedEver = uploadedEver,
DownloadedEver = downloadedEver
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Ratio;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(3600L, 3600L)] // 1 hour
[InlineData(0L, 0L)]
[InlineData(-1L, -1L)] // Unknown/infinite
[InlineData(null, 0L)]
public void Eta_ReturnsCorrectValue(long? eta, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { Eta = eta };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(86400L, 86400L)] // 1 day
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void SeedingTimeSeconds_ReturnsCorrectValue(long? secondsSeeding, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { SecondsSeeding = secondsSeeding };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.SeedingTimeSeconds;
// Assert
result.ShouldBe(expected);
}
[Fact]
public void IsIgnored_WithEmptyList_ReturnsFalse()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = "abc123", Name = "Test Torrent" };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.IsIgnored(Array.Empty<string>());
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsIgnored_MatchingHash_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = "abc123", Name = "Test Torrent" };
var wrapper = new TransmissionItemWrapper(torrentInfo);
var ignoredDownloads = new[] { "abc123" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingCategory_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo
{
HashString = "abc123",
Name = "Test Torrent",
DownloadDir = "/downloads/test-category"
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
var ignoredDownloads = new[] { "test-category" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo
{
HashString = "abc123",
Name = "Test Torrent",
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker.example.com/announce" }
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var torrentInfo = new TorrentInfo
{
HashString = "abc123",
Name = "Test Torrent",
Labels = new[] { "some-category" },
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker.example.com/announce" }
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
var ignoredDownloads = new[] { "notmatching" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeFalse();
}
}

View File

@@ -0,0 +1,906 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixture>
{
private readonly TransmissionServiceFixture _fixture;
public TransmissionServiceDCTests(TransmissionServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class GetSeedingDownloads_Tests : TransmissionServiceDCTests
{
public GetSeedingDownloads_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FiltersStatus5And6()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { HashString = "hash1", Name = "Torrent 1", Status = 5 }, // Seeding
new TorrentInfo { HashString = "hash2", Name = "Torrent 2", Status = 4 }, // Downloading
new TorrentInfo { HashString = "hash3", Name = "Torrent 3", Status = 6 } // Seeding
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, item => Assert.NotNull(item.Hash));
}
[Fact]
public async Task ReturnsEmptyList_WhenNull()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync((TransmissionTorrents?)null);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SkipsTorrentsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { HashString = "", Name = "No Hash", Status = 5 },
new TorrentInfo { HashString = "hash1", Name = "Valid Hash", Status = 5 }
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public async Task ReturnsEmptyList_WhenTorrentsNull()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new TransmissionTorrents
{
Torrents = null
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : TransmissionServiceDCTests
{
public FilterDownloadsToBeCleanedAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash3", DownloadDir = "/downloads/music" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, x => x.Category == "movies");
Assert.Contains(result, x => x.Category == "tv");
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void ReturnsEmptyList_WhenNoMatches()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class FilterDownloadsToChangeCategoryAsync_Tests : TransmissionServiceDCTests
{
public FilterDownloadsToChangeCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void FiltersCorrectly()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void SkipsDownloadsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "", DownloadDir = "/downloads/movies" }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class CreateCategoryAsync_Tests : TransmissionServiceDCTests
{
public CreateCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task IsNoOp()
{
// Arrange
var sut = _fixture.CreateSut();
// Act
await sut.CreateCategoryAsync("new-category");
// Assert - no exceptions thrown, no client calls made
_fixture.ClientWrapper.VerifyNoOtherCalls();
}
}
public class DeleteDownload_Tests : TransmissionServiceDCTests
{
public DeleteDownload_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task GetsIdFromHash_ThenDeletes()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { Id = 123, HashString = hash }
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.ClientWrapper
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true),
Times.Once);
}
[Fact]
public async Task HandlesNotFound()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "nonexistent-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync((TransmissionTorrents?)null);
// Act
await sut.DeleteDownload(hash);
// Assert - no exception thrown
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), It.IsAny<bool>()),
Times.Never);
}
[Fact]
public async Task DeletesWithData()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { Id = 123, HashString = hash }
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
// Act
await sut.DeleteDownload(hash);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true),
Times.Once);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : TransmissionServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NullDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task EmptyDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingHash_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "", Name = "Test", DownloadDir = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingName_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "", DownloadDir = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingDownloadDir_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingFiles_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "/downloads", Files = null })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingFileStats_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads",
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = null
})
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task NoHardlinks_ChangesLocation()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = baseDownloadDir,
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads/movies",
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task FileNotFound_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads/movies",
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task UnwantedFiles_IgnoredInCheck()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads/movies",
Files = new[]
{
new TransmissionTorrentFiles { Name = "file1.mkv" },
new TransmissionTorrentFiles { Name = "file2.mkv" }
},
FileStats = new[]
{
new TransmissionTorrentFileStats { Wanted = false },
new TransmissionTorrentFileStats { Wanted = true }
}
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
}
[Fact]
public async Task WithIgnoredRootDir_PopulatesFileCounts()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads/movies",
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
Times.Once);
}
[Fact]
public async Task PublishesCategoryChangedEvent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = baseDownloadDir,
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
}
[Fact]
public async Task AppendsTargetCategoryToBasePath()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies", "subfolder");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = baseDownloadDir,
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
}
}
}

View File

@@ -0,0 +1,118 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceFixture : IDisposable
{
public Mock<ILogger<TransmissionService>> Logger { get; }
public MemoryCache Cache { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public BlocklistProvider BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<ITransmissionClientWrapper> ClientWrapper { get; }
public TransmissionServiceFixture()
{
Logger = new Mock<ILogger<TransmissionService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = TestBlocklistProviderFactory.Create();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<ITransmissionClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public TransmissionService CreateSut(DownloadClientConfig? config = null)
{
config ??= new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Test Client",
TypeName = Domain.Enums.DownloadClientTypeName.Transmission,
Type = Domain.Enums.DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost:9091"),
Username = "admin",
Password = "admin",
UrlBase = "/transmission"
};
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.Returns(httpClient);
return new TransmissionService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,718 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Moq;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture>
{
private readonly TransmissionServiceFixture _fixture;
public TransmissionServiceTests(TransmissionServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_BasicScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentNotFound_ReturnsEmptyResult()
{
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync((TransmissionTorrents?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = true,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.True(result.IsPrivate);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.False(result.IsPrivate);
}
}
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task AllFilesUnwanted_DeletesFromClient()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
FileStats = new[]
{
new TransmissionTorrentFileStats { Wanted = false },
new TransmissionTorrentFileStats { Wanted = false }
}
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task SomeFilesWanted_DoesNotRemove()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
RateDownload = 1000,
FileStats = new[]
{
new TransmissionTorrentFileStats { Wanted = false },
new TransmissionTorrentFileStats { Wanted = true }
}
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string category = "test-category";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
Labels = new[] { category },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_MissingFileStatsScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_MissingFileStatsScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FilesWithMissingWantedStatus_DoesNotRemove()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
RateDownload = 1000,
FileStats = new[]
{
new TransmissionTorrentFileStats { Wanted = null },
new TransmissionTorrentFileStats { Wanted = false }
}
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NotDownloadingState_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 6,
IsPrivate = false,
RateDownload = 0,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
}
[Fact]
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
RateDownload = 0,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
}
}
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
RateDownload = 1000,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
RateDownload = 0,
Eta = 0,
IsPrivate = false,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
}

View File

@@ -109,95 +109,173 @@ public class UTorrentItemWrapperTests
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
[Theory]
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
[InlineData(0L, 0L)]
public void DownloadedBytes_ReturnsCorrectValue(long downloaded, long expected)
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker2.example.com/announce\r\nudp://tracker3.example.com:1337/announce"
};
var torrentItem = new UTorrentItem { Downloaded = downloaded };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
var result = wrapper.DownloadedBytes;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
result.ShouldBe(expected);
}
[Theory]
[InlineData(2000, 2.0)] // 2000 permille = 2.0 ratio
[InlineData(500, 0.5)] // 500 permille = 0.5 ratio
[InlineData(1000, 1.0)] // 1000 permille = 1.0 ratio
[InlineData(0, 0.0)] // No ratio
public void Ratio_ReturnsCorrectValue(int ratioRaw, double expected)
{
// Arrange
var torrentItem = new UTorrentItem { RatioRaw = ratioRaw };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Ratio;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(3600, 3600L)] // 1 hour
[InlineData(0, 0L)]
[InlineData(-1, -1L)] // Unknown/infinite
public void Eta_ReturnsCorrectValue(int eta, long expected)
{
// Arrange
var torrentItem = new UTorrentItem { ETA = eta };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe(expected);
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
public void SeedingTimeSeconds_WithCompletedDate_ReturnsPositiveValue()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker1.example.com/announce\r\nudp://tracker1.example.com:1337/announce"
};
// Arrange - Set DateCompleted to 1 hour ago
var oneHourAgo = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
var torrentItem = new UTorrentItem { DateCompleted = oneHourAgo };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
var result = wrapper.SeedingTimeSeconds;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
// Assert - Should be approximately 3600 seconds (1 hour), allow some tolerance
result.ShouldBeInRange(3599L, 3601L);
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
public void SeedingTimeSeconds_WithNoCompletedDate_ReturnsZero()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://valid.example.com/announce\r\ninvalid-url\r\n\r\n "
};
// Arrange - DateCompleted = 0 means not completed
var torrentItem = new UTorrentItem { DateCompleted = 0 };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
var result = wrapper.SeedingTimeSeconds;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
result.ShouldBe(0L);
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
public void IsIgnored_WithEmptyList_ReturnsFalse()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = ""
};
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
var result = wrapper.IsIgnored(Array.Empty<string>());
// Assert
result.ShouldBeEmpty();
result.ShouldBeFalse();
}
[Fact]
public void Trackers_WithNullTrackerList_ReturnsEmptyList()
public void IsIgnored_MatchingHash_ReturnsTrue()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties(); // Trackers defaults to empty string
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
var ignoredDownloads = new[] { "abc123" };
// Act
var result = wrapper.Trackers;
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeEmpty();
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingCategory_ReturnsTrue()
{
// Arrange
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent", Label = "test-category" };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
var ignoredDownloads = new[] { "test-category" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker.example.com/announce"
};
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent", Label = "some-category" };
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker.example.com/announce"
};
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
var ignoredDownloads = new[] { "notmatching" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeFalse();
}
}

View File

@@ -0,0 +1,724 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
{
private readonly UTorrentServiceFixture _fixture;
public UTorrentServiceDCTests(UTorrentServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class GetSeedingDownloads_Tests : UTorrentServiceDCTests
{
public GetSeedingDownloads_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FiltersSeedingTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new List<UTorrentItem>
{
new UTorrentItem { Hash = "hash1", Name = "Torrent 1", Status = 9, DateCompleted = 1000 }, // Seeding (Started + Checked, DateCompleted > 0)
new UTorrentItem { Hash = "hash2", Name = "Torrent 2", Status = 9, DateCompleted = 0 }, // Downloading (Started + Checked, DateCompleted = 0)
new UTorrentItem { Hash = "hash3", Name = "Torrent 3", Status = 9, DateCompleted = 2000 } // Seeding (Started + Checked, DateCompleted > 0)
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash3"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" });
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Equal(2, result.Count);
}
[Fact]
public async Task ReturnsEmptyList_WhenNoSeedingTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new List<UTorrentItem>
{
new UTorrentItem { Hash = "hash1", Name = "Torrent 1", Status = 9 } // Not seeding
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SkipsTorrentsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new List<UTorrentItem>
{
new UTorrentItem { Hash = "", Name = "No Hash", Status = 9, DateCompleted = 1000 },
new UTorrentItem { Hash = "hash1", Name = "Valid Hash", Status = 9, DateCompleted = 1000 }
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : UTorrentServiceDCTests
{
public FilterDownloadsToBeCleanedAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }),
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash2", Label = "tv" }, new UTorrentProperties { Hash = "hash2", Pex = 1, Trackers = "" }),
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash3", Label = "music" }, new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, x => x.Category == "movies");
Assert.Contains(result, x => x.Category == "tv");
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void ReturnsEmptyList_WhenNoMatches()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "music" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
var categories = new List<CleanCategory>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class FilterDownloadsToChangeCategoryAsync_Tests : UTorrentServiceDCTests
{
public FilterDownloadsToChangeCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void FiltersCorrectly()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }),
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash2", Label = "tv" }, new UTorrentProperties { Hash = "hash2", Pex = 1, Trackers = "" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void SkipsDownloadsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "", Label = "movies" }, new UTorrentProperties { Hash = "", Pex = 1, Trackers = "" }),
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class CreateCategoryAsync_Tests : UTorrentServiceDCTests
{
public CreateCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task IsNoOp()
{
// Arrange
var sut = _fixture.CreateSut();
// Act
await sut.CreateCategoryAsync("new-category");
// Assert - no exceptions thrown, no client calls made
}
}
public class DeleteDownload_Tests : UTorrentServiceDCTests
{
public DeleteDownload_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CallsClientDelete()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash"))))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash"))),
Times.Once);
}
[Fact]
public async Task NormalizesHashToLowercase()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>()))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("uppercase-hash"))),
Times.Once);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : UTorrentServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NullDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task EmptyDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingHash_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "", Pex = 1, Trackers = "" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingName_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingCategory_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task NoHardlinks_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
Times.Once);
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task FileNotFound_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SkippedFiles_IgnoredInCheck()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
}
[Fact]
public async Task WithIgnoredRootDir_PopulatesFileCounts()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
Times.Once);
}
[Fact]
public async Task PublishesCategoryChangedEvent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
Times.Once);
}
[Fact]
public async Task NullFilesResponse_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync((List<UTorrentFile>?)null);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - When files is null, it uses empty collection and proceeds to change label
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync("hash1", "unlinked"), Times.Once);
}
}
}

View File

@@ -0,0 +1,118 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceFixture : IDisposable
{
public Mock<ILogger<UTorrentService>> Logger { get; }
public MemoryCache Cache { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public BlocklistProvider BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IUTorrentClientWrapper> ClientWrapper { get; }
public UTorrentServiceFixture()
{
Logger = new Mock<ILogger<UTorrentService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = TestBlocklistProviderFactory.Create();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IUTorrentClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public UTorrentService CreateSut(DownloadClientConfig? config = null)
{
config ??= new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Test Client",
TypeName = Domain.Enums.DownloadClientTypeName.uTorrent,
Type = Domain.Enums.DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "admin",
UrlBase = "/gui/"
};
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.Returns(httpClient);
return new UTorrentService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,613 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
{
private readonly UTorrentServiceFixture _fixture;
public UTorrentServiceTests(UTorrentServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_BasicScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentNotFound_ReturnsEmptyResult()
{
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync((UTorrentItem?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked = 1 + 8
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = -1, // -1 means private torrent
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.True(result.IsPrivate);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked = 1 + 8
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1, // 1 means public torrent (PEX enabled)
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.False(result.IsPrivate);
}
}
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task AllFilesUnwanted_DeletesFromClient()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 0, Index = 1, Size = 2000, Downloaded = 0 }
});
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task SomeFilesWanted_DoesNotRemove()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string category = "test-category";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000,
Label = category
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByTrackerDomain_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string trackerDomain = "tracker.example.com";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = $"https://{trackerDomain}/announce\r\n"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_ExceptionHandlingScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_ExceptionHandlingScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task GetTorrentFilesAsync_ThrowsException_ContinuesProcessing()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NotDownloadingState_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 32, // Paused
DownloadSpeed = 0
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
}
[Fact]
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked
DownloadSpeed = 0
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
}
}
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked
DownloadSpeed = 0,
ETA = 0
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
}

View File

@@ -0,0 +1,164 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter.Consumers;
public class DownloadHunterConsumerTests
{
private readonly Mock<ILogger<DownloadHunterConsumer<SearchItem>>> _loggerMock;
private readonly Mock<IDownloadHunter> _downloadHunterMock;
private readonly DownloadHunterConsumer<SearchItem> _consumer;
public DownloadHunterConsumerTests()
{
_loggerMock = new Mock<ILogger<DownloadHunterConsumer<SearchItem>>>();
_downloadHunterMock = new Mock<IDownloadHunter>();
_consumer = new DownloadHunterConsumer<SearchItem>(_loggerMock.Object, _downloadHunterMock.Object);
}
#region Consume Tests
[Fact]
public async Task Consume_CallsHuntDownloadsAsync()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(request), Times.Once);
}
[Fact]
public async Task Consume_WhenHunterThrows_LogsErrorAndDoesNotRethrow()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.ThrowsAsync(new Exception("Hunt failed"));
// Act - Should not throw
await _consumer.Consume(contextMock.Object);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to search for replacement")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task Consume_PassesCorrectRequestToHunter()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
DownloadHuntRequest<SearchItem>? capturedRequest = null;
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Callback<DownloadHuntRequest<SearchItem>>(r => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
}
[Fact]
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
{
// Arrange
var request = new DownloadHuntRequest<SearchItem>
{
InstanceType = InstanceType.Lidarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 999 },
Record = CreateQueueRecord()
};
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(
It.Is<DownloadHuntRequest<SearchItem>>(r => r.InstanceType == InstanceType.Lidarr)), Times.Once);
}
#endregion
#region Helper Methods
private static DownloadHuntRequest<SearchItem> CreateHuntRequest()
{
return new DownloadHuntRequest<SearchItem>
{
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://radarr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
private static Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>> CreateConsumeContextMock(DownloadHuntRequest<SearchItem> message)
{
var mock = new Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>>();
mock.Setup(c => c.Message).Returns(message);
return mock;
}
#endregion
}

View File

@@ -0,0 +1,311 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Data.Models.Arr;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Time.Testing;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter;
public class DownloadHunterTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly FakeTimeProvider _fakeTimeProvider;
private readonly Infrastructure.Features.DownloadHunter.DownloadHunter _downloadHunter;
private readonly SqliteConnection _connection;
public DownloadHunterTests()
{
// Use SQLite in-memory with shared connection to support complex types
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(_connection)
.Options;
_dataContext = new DataContext(options);
_dataContext.Database.EnsureCreated();
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_fakeTimeProvider = new FakeTimeProvider();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
.Returns(_arrClientMock.Object);
_downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter(
_dataContext,
_arrClientFactoryMock.Object,
_fakeTimeProvider
);
}
public void Dispose()
{
_dataContext.Dispose();
_connection.Dispose();
}
#region HuntDownloadsAsync - Search Disabled Tests
[Fact]
public async Task HuntDownloadsAsync_WhenSearchDisabled_DoesNotCallArrClient()
{
// Arrange
await SetupGeneralConfig(searchEnabled: false);
var request = CreateHuntRequest();
// Act
await _downloadHunter.HuntDownloadsAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>()), Times.Never);
_arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()), Times.Never);
}
[Fact]
public async Task HuntDownloadsAsync_WhenSearchDisabled_ReturnsImmediately()
{
// Arrange
await SetupGeneralConfig(searchEnabled: false);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Should complete without needing to advance time
var completedTask = await Task.WhenAny(task, Task.Delay(100));
Assert.Same(task, completedTask);
}
#endregion
#region HuntDownloadsAsync - Search Enabled Tests
[Fact]
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsArrClientFactory()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act - Start the task and advance time
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType), Times.Once);
}
[Fact]
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsSearchItemsAsync()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientMock.Verify(
c => c.SearchItemsAsync(
request.Instance,
It.Is<HashSet<SearchItem>>(s => s.Contains(request.SearchItem))),
Times.Once);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task HuntDownloadsAsync_UsesCorrectInstanceType(InstanceType instanceType)
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest(instanceType);
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
}
#endregion
#region HuntDownloadsAsync - Delay Tests
[Fact]
public async Task HuntDownloadsAsync_WaitsForConfiguredDelay()
{
// Arrange
const ushort configuredDelay = 120;
await SetupGeneralConfig(searchEnabled: true, searchDelay: configuredDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Task should not complete before advancing time
Assert.False(task.IsCompleted);
// Advance partial time - should still not complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(configuredDelay - 1));
await Task.Delay(10); // Give the task a chance to complete if it would
Assert.False(task.IsCompleted);
// Advance remaining time - should now complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayBelowMinimum_UsesDefaultDelay()
{
// Arrange - Set delay below minimum (simulating manual DB edit)
const ushort belowMinDelay = 10; // Below MinSearchDelaySeconds (60)
await SetupGeneralConfig(searchEnabled: true, searchDelay: belowMinDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by the below-min value - should NOT complete because it should use default
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(belowMinDelay));
await Task.Delay(10);
Assert.False(task.IsCompleted);
// Advance to default delay - should now complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds - belowMinDelay));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayIsZero_UsesDefaultDelay()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: 0);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Should not complete immediately
Assert.False(task.IsCompleted);
// Advance to default delay
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayAtMinimum_UsesConfiguredDelay()
{
// Arrange - Set delay exactly at minimum
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by minimum - should complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayAboveMinimum_UsesConfiguredDelay()
{
// Arrange - Set delay above minimum
const ushort aboveMinDelay = 180;
await SetupGeneralConfig(searchEnabled: true, searchDelay: aboveMinDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by minimum - should NOT complete yet
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await Task.Delay(10);
Assert.False(task.IsCompleted);
// Advance remaining time
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(aboveMinDelay - Constants.MinSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
#endregion
#region Helper Methods
private async Task SetupGeneralConfig(bool searchEnabled, ushort searchDelay = Constants.DefaultSearchDelaySeconds)
{
var generalConfig = new GeneralConfig
{
SearchEnabled = searchEnabled,
SearchDelay = searchDelay
};
_dataContext.GeneralConfigs.Add(generalConfig);
await _dataContext.SaveChangesAsync();
}
private static DownloadHuntRequest<SearchItem> CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr)
{
return new DownloadHuntRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
#endregion
}

View File

@@ -0,0 +1,227 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover.Consumers;
public class DownloadRemoverConsumerTests
{
private readonly Mock<ILogger<DownloadRemoverConsumer<SearchItem>>> _loggerMock;
private readonly Mock<IQueueItemRemover> _queueItemRemoverMock;
private readonly DownloadRemoverConsumer<SearchItem> _consumer;
public DownloadRemoverConsumerTests()
{
_loggerMock = new Mock<ILogger<DownloadRemoverConsumer<SearchItem>>>();
_queueItemRemoverMock = new Mock<IQueueItemRemover>();
_consumer = new DownloadRemoverConsumer<SearchItem>(_loggerMock.Object, _queueItemRemoverMock.Object);
}
#region Consume Tests
[Fact]
public async Task Consume_CallsRemoveQueueItemAsync()
{
// Arrange
var request = CreateRemoveRequest();
var contextMock = CreateConsumeContextMock(request);
_queueItemRemoverMock
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(request), Times.Once);
}
[Fact]
public async Task Consume_WhenRemoverThrows_LogsErrorAndDoesNotRethrow()
{
// Arrange
var request = CreateRemoveRequest();
var contextMock = CreateConsumeContextMock(request);
_queueItemRemoverMock
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
.ThrowsAsync(new Exception("Remove failed"));
// Act - Should not throw
await _consumer.Consume(contextMock.Object);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to remove queue item")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task Consume_PassesCorrectRequestToRemover()
{
// Arrange
var request = CreateRemoveRequest();
var contextMock = CreateConsumeContextMock(request);
QueueItemRemoveRequest<SearchItem>? capturedRequest = null;
_queueItemRemoverMock
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
.Callback<QueueItemRemoveRequest<SearchItem>>(r => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
Assert.Equal(request.RemoveFromClient, capturedRequest.RemoveFromClient);
Assert.Equal(request.DeleteReason, capturedRequest.DeleteReason);
}
[Fact]
public async Task Consume_WithRemoveFromClientTrue_PassesCorrectly()
{
// Arrange
var request = new QueueItemRemoveRequest<SearchItem>
{
InstanceType = InstanceType.Sonarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 456 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.Stalled
};
var contextMock = CreateConsumeContextMock(request);
_queueItemRemoverMock
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
It.Is<QueueItemRemoveRequest<SearchItem>>(req =>
req.RemoveFromClient == true &&
req.DeleteReason == DeleteReason.Stalled)), Times.Once);
}
[Fact]
public async Task Consume_WithDifferentDeleteReasons_HandlesCorrectly()
{
// Arrange
var request = new QueueItemRemoveRequest<SearchItem>
{
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 789 },
Record = CreateQueueRecord(),
RemoveFromClient = false,
DeleteReason = DeleteReason.FailedImport
};
var contextMock = CreateConsumeContextMock(request);
_queueItemRemoverMock
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
It.Is<QueueItemRemoveRequest<SearchItem>>(req =>
req.DeleteReason == DeleteReason.FailedImport)), Times.Once);
}
[Fact]
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
{
// Arrange
var request = new QueueItemRemoveRequest<SearchItem>
{
InstanceType = InstanceType.Readarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 111 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.SlowSpeed
};
var contextMock = CreateConsumeContextMock(request);
_queueItemRemoverMock
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
It.Is<QueueItemRemoveRequest<SearchItem>>(req => req.InstanceType == InstanceType.Readarr)), Times.Once);
}
#endregion
#region Helper Methods
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest()
{
return new QueueItemRemoveRequest<SearchItem>
{
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.Stalled
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://radarr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
private static Mock<ConsumeContext<QueueItemRemoveRequest<SearchItem>>> CreateConsumeContextMock(QueueItemRemoveRequest<SearchItem> message)
{
var mock = new Mock<ConsumeContext<QueueItemRemoveRequest<SearchItem>>>();
mock.Setup(c => c.Message).Returns(message);
return mock;
}
#endregion
}

View File

@@ -0,0 +1,484 @@
using System.Net;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
public class QueueItemRemoverTests : IDisposable
{
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
private readonly Mock<IBus> _busMock;
private readonly MemoryCache _memoryCache;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly EventPublisher _eventPublisher;
private readonly EventsContext _eventsContext;
private readonly QueueItemRemover _queueItemRemover;
public QueueItemRemoverTests()
{
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
_busMock = new Mock<IBus>();
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
.Returns(_arrClientMock.Object);
// Create real EventPublisher with mocked dependencies
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_eventsContext = new EventsContext(eventsContextOptions);
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
// Setup interceptor to execute the action with params using DynamicInvoke
dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
var result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
});
_eventPublisher = new EventPublisher(
_eventsContext,
hubContextMock.Object,
Mock.Of<ILogger<EventPublisher>>(),
Mock.Of<INotificationPublisher>(),
dryRunInterceptorMock.Object);
_queueItemRemover = new QueueItemRemover(
_loggerMock.Object,
_busMock.Object,
_memoryCache,
_arrClientFactoryMock.Object,
_eventPublisher
);
// Clear static RecurringHashes before each test
Striker.RecurringHashes.Clear();
}
public void Dispose()
{
_memoryCache.Dispose();
_eventsContext.Dispose();
Striker.RecurringHashes.Clear();
}
#region RemoveQueueItemAsync - Success Tests
[Fact]
public async Task RemoveQueueItemAsync_Success_DeletesQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
request.Instance,
request.Record,
request.RemoveFromClient,
request.DeleteReason), Times.Once);
}
[Fact]
public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
DownloadHuntRequest<SearchItem>? capturedRequest = null;
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
_busMock
.Setup(b => b.Publish(It.IsAny<DownloadHuntRequest<SearchItem>>(), It.IsAny<CancellationToken>()))
.Callback<DownloadHuntRequest<SearchItem>, CancellationToken>((r, _) => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest!.InstanceType);
Assert.Equal(request.Instance, capturedRequest.Instance);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
}
[Fact]
public async Task RemoveQueueItemAsync_Success_ClearsDownloadMarkedForRemovalCache()
{
// Arrange
var request = CreateRemoveRequest();
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
_memoryCache.Set(cacheKey, true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task RemoveQueueItemAsync_UsesCorrectClientForInstanceType(InstanceType instanceType)
{
// Arrange
var request = CreateRemoveRequest(instanceType);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
}
#endregion
#region RemoveQueueItemAsync - Recurring Hash Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
var hash = request.Record.DownloadId.ToLowerInvariant();
Striker.RecurringHashes.TryAdd(hash, null);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_RemovesHashFromRecurring()
{
// Arrange
var request = CreateRemoveRequest();
var hash = request.Record.DownloadId.ToLowerInvariant();
Striker.RecurringHashes.TryAdd(hash, null);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
}
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
}
#endregion
#region RemoveQueueItemAsync - HTTP Error Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenNotFoundError_ThrowsWithItemAlreadyDeletedMessage()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Contains("might have already been deleted", exception.Message);
Assert.Contains(request.InstanceType.ToString(), exception.Message);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenNotFoundError_ClearsCacheInFinally()
{
// Arrange
var request = CreateRemoveRequest();
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
_memoryCache.Set(cacheKey, true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
// Cache should be cleared in finally block
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
}
[Fact]
public async Task RemoveQueueItemAsync_WhenOtherHttpError_Rethrows()
{
// Arrange
var request = CreateRemoveRequest();
var originalException = new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<HttpRequestException>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Same(originalException, exception);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenNonHttpError_Rethrows()
{
// Arrange
var request = CreateRemoveRequest();
var originalException = new InvalidOperationException("Some other error");
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Same(originalException, exception);
}
#endregion
#region RemoveQueueItemAsync - Delete Reason Tests
[Theory]
[InlineData(DeleteReason.Stalled)]
[InlineData(DeleteReason.FailedImport)]
[InlineData(DeleteReason.SlowSpeed)]
[InlineData(DeleteReason.SlowTime)]
[InlineData(DeleteReason.DownloadingMetadata)]
[InlineData(DeleteReason.MalwareFileFound)]
public async Task RemoveQueueItemAsync_PassesCorrectDeleteReason(DeleteReason deleteReason)
{
// Arrange
var request = CreateRemoveRequest(deleteReason: deleteReason);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
deleteReason), Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RemoveQueueItemAsync_PassesCorrectRemoveFromClientFlag(bool removeFromClient)
{
// Arrange
var request = CreateRemoveRequest(removeFromClient: removeFromClient);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
removeFromClient,
It.IsAny<DeleteReason>()), Times.Once);
}
#endregion
#region Helper Methods
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
InstanceType instanceType = InstanceType.Sonarr,
bool removeFromClient = true,
DeleteReason deleteReason = DeleteReason.Stalled)
{
return new QueueItemRemoveRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123DEF456"
};
}
#endregion
}

View File

@@ -0,0 +1,916 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
[Collection(JobHandlerCollection.Name)]
public class DownloadCleanerTests : IDisposable
{
private readonly JobHandlerFixture _fixture;
private readonly Mock<ILogger<DownloadCleaner>> _logger;
public DownloadCleanerTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = _fixture.CreateLogger<DownloadCleaner>();
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
private DownloadCleaner CreateSut()
{
return new DownloadCleaner(
_logger.Object,
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus.Object,
_fixture.ArrClientFactory.Object,
_fixture.ArrQueueIterator.Object,
_fixture.DownloadServiceFactory.Object,
_fixture.EventPublisher.Object,
_fixture.TimeProvider
);
}
/// <summary>
/// Executes the handler and advances time past the 10-second delay
/// </summary>
private async Task ExecuteWithTimeAdvance(DownloadCleaner sut)
{
var task = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
await task;
}
#region ExecuteAsync Tests (inherited from GenericHandler)
[Fact]
public async Task ExecuteAsync_LoadsAllConfigsIntoContextProvider()
{
// Arrange
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - verify configs were loaded (by checking the handler completed without errors)
// The configs are loaded into ContextProvider which is AsyncLocal scoped
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no download clients")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region ExecuteInternalAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
{
// Arrange
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no download clients are configured")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenNoFeaturesEnabled_LogsWarningAndReturns()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - should warn about no seeding downloads or no features enabled
// The exact message depends on the order of checks
_logger.Verify(
x => x.Log(
It.IsAny<LogLevel>(),
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.AtLeastOnce
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenNoSeedingDownloadsFound_LogsInfoAndReturns()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No seeding downloads found")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_FiltersOutIgnoredDownloads()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
// Add ignored download to general config
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
generalConfig.IgnoredDownloads = ["ignored-hash"];
_fixture.DataContext.SaveChanges();
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("ignored-hash");
mockTorrent.Setup(x => x.Name).Returns("Ignored Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - the download should be skipped
_logger.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("download is ignored")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_FiltersOutDownloadsUsedByArrs()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("arr-download-hash");
mockTorrent.Setup(x => x.Name).Returns("Arr Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
// Setup arr client to return queue record with matching download ID
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "arr-download-hash",
Title = "Test Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - the download should be skipped because it's used by an arr
_logger.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("download is used by an arr")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_ProcessesAllArrConfigs()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
// Need at least one download for arr processing to occur
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
))
.Returns([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(Task.CompletedTask);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - both instances should be processed
_fixture.ArrClientFactory.Verify(
x => x.GetClient(InstanceType.Sonarr),
Times.Once
);
_fixture.ArrClientFactory.Verify(
x => x.GetClient(InstanceType.Radarr),
Times.Once
);
}
#endregion
#region ChangeUnlinkedCategoriesAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenUnlinkedEnabled_EvaluatesDownloadsForHardlinks()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.Returns(Task.CompletedTask);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("hardlinks")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region CleanDownloadsAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenCategoriesConfigured_EvaluatesDownloadsForCleaning()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext, "completed", 1.0, 60);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
))
.Returns(Task.CompletedTask);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("cleanup")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region ProcessInstanceAsync Tests
[Fact]
public async Task ProcessInstanceAsync_CollectsDownloadIdsFromArrQueue()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
// Need at least one download for arr processing to occur
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
))
.Returns([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecords = new List<QueueRecord>
{
new() { Id = 1, DownloadId = "hash1", Title = "Download 1", Protocol = "torrent" },
new() { Id = 2, DownloadId = "hash2", Title = "Download 2", Protocol = "torrent" }
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
mockArrClient.Object,
It.Is<ArrInstance>(i => i.Id == sonarrInstance.Id),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback(queueRecords);
});
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - verify the iterator was called
_fixture.ArrQueueIterator.Verify(
x => x.Iterate(
mockArrClient.Object,
It.Is<ArrInstance>(i => i.Id == sonarrInstance.Id),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
),
Times.Once
);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task ExecuteInternalAsync_WhenDownloadServiceFails_LogsErrorAndContinues()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Failing Client");
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Working Client");
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
var failingService = _fixture.CreateMockDownloadService("Failing Client");
failingService
.Setup(x => x.GetSeedingDownloads())
.ThrowsAsync(new Exception("Connection failed"));
var workingService = _fixture.CreateMockDownloadService("Working Client");
workingService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([]);
var callCount = 0;
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(() =>
{
callCount++;
return callCount == 1 ? failingService.Object : workingService.Object;
});
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to get seeding downloads")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ChangeUnlinkedCategoriesAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
))
.Throws(new Exception("Filter failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for hardlinks evaluation")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ChangeUnlinkedCategoriesAsync_WhenCreateCategoryThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.ThrowsAsync(new Exception("Create category failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to create category")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ChangeUnlinkedCategoriesAsync_WhenChangeCategoryThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.ThrowsAsync(new Exception("Change category failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to change category for download client")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task CleanDownloadsAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
))
.Throws(new Exception("Filter failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for cleaning")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task CleanDownloadsAsync_WhenCleanDownloadsThrows_LogsErrorAndContinues()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
))
.ThrowsAsync(new Exception("Clean failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to clean downloads for download client")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ProcessArrConfigAsync_WhenArrIteratorThrows_LogsErrorAndRethrows()
{
// Arrange - DownloadCleaner calls ProcessArrConfigAsync with throwOnFailure=true
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
))
.Returns([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
// Make the arr queue iterator throw an exception
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.ThrowsAsync(new InvalidOperationException("Arr connection failed"));
var sut = CreateSut();
// Act & Assert - exception should propagate since throwOnFailure=true
// Need to advance time for the delay to pass before the exception is thrown
var task = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => task);
Assert.Equal("Arr connection failed", exception.Message);
// Verify error was logged
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to process")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
}

View File

@@ -0,0 +1,609 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using MalwareBlockerJob = Cleanuparr.Infrastructure.Features.Jobs.MalwareBlocker;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
[Collection(JobHandlerCollection.Name)]
public class MalwareBlockerTests : IDisposable
{
private readonly JobHandlerFixture _fixture;
private readonly Mock<ILogger<MalwareBlockerJob>> _logger;
public MalwareBlockerTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = _fixture.CreateLogger<MalwareBlockerJob>();
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
private MalwareBlockerJob CreateSut()
{
return new MalwareBlockerJob(
_logger.Object,
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus.Object,
_fixture.ArrClientFactory.Object,
_fixture.ArrQueueIterator.Object,
_fixture.DownloadServiceFactory.Object,
_fixture.BlocklistProvider.Object,
_fixture.EventPublisher.Object
);
}
#region ExecuteInternalAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
{
// Arrange
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No download clients configured")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenNoBlocklistsEnabled_LogsWarningAndReturns()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No blocklists are enabled")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenBlocklistEnabled_LoadsBlocklists()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(Task.CompletedTask);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_fixture.BlocklistProvider.Verify(x => x.LoadBlocklistsAsync(), Times.Once);
}
[Fact]
public async Task ExecuteInternalAsync_WhenSonarrEnabled_ProcessesSonarrInstances()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(Task.CompletedTask);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
}
[Fact]
public async Task ExecuteInternalAsync_WhenDeleteKnownMalwareEnabled_ProcessesAllArrs()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
contentBlockerConfig.DeleteKnownMalware = true;
// Need at least one blocklist enabled for processing to occur
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(Task.CompletedTask);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - Sonarr and Radarr processed because DeleteKnownMalware is true
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), Times.Once);
}
#endregion
#region ProcessInstanceAsync Tests
[Fact]
public async Task ProcessInstanceAsync_SkipsIgnoredDownloads()
{
// Arrange
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
generalConfig.IgnoredDownloads = ["ignored-download-id"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "ignored-download-id",
Title = "Ignored Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ignored")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ProcessInstanceAsync_ChecksTorrentClientsForBlockedFiles()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "torrent-download-id",
Title = "Torrent Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult { Found = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
mockDownloadService.Verify(
x => x.BlockUnwantedFilesAsync("torrent-download-id", It.IsAny<List<string>>()),
Times.Once
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemove_PublishesRemoveRequest()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "malware-download-id",
Title = "Malware Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.DeleteReason == DeleteReason.AllFilesBlocked
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenPrivateAndDeletePrivateFalse_DoesNotRemoveFromClient()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
// Ensure DeletePrivate is false
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
contentBlockerConfig.DeletePrivate = false;
_fixture.DataContext.SaveChanges();
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "private-malware-id",
Title = "Private Malware",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = true,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - RemoveFromClient should be false
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.RemoveFromClient == false
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenDownloadNotFoundInTorrentClient_LogsWarning()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "missing-download-id",
Title = "Missing Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult { Found = false });
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Download not found in any torrent client")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task ProcessInstanceAsync_WhenDownloadServiceThrows_LogsErrorAndContinues()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "error-download-id",
Title = "Error Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ThrowsAsync(new Exception("Connection failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error checking download")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region Helper Methods
private void EnableSonarrBlocklist()
{
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
_fixture.DataContext.SaveChanges();
}
#endregion
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// Collection definition for job handler tests that share <see cref="JobHandlerFixture"/>.
/// Tests in this collection run sequentially to avoid FakeTimeProvider interference.
/// </summary>
[CollectionDefinition(Name)]
public class JobHandlerCollection : ICollectionFixture<JobHandlerFixture>
{
public const string Name = "JobHandler";
}

View File

@@ -0,0 +1,130 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Persistence;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// Base fixture for job handler tests providing common mock dependencies
/// </summary>
public class JobHandlerFixture : IDisposable
{
public DataContext DataContext { get; private set; }
public MemoryCache Cache { get; }
public Mock<IBus> MessageBus { get; }
public Mock<IArrClientFactory> ArrClientFactory { get; }
public Mock<IArrQueueIterator> ArrQueueIterator { get; }
public Mock<IDownloadServiceFactory> DownloadServiceFactory { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public FakeTimeProvider TimeProvider { get; private set; }
public JobHandlerFixture()
{
DataContext = TestDataContextFactory.Create();
Cache = new MemoryCache(new MemoryCacheOptions());
MessageBus = new Mock<IBus>();
ArrClientFactory = new Mock<IArrClientFactory>();
ArrQueueIterator = new Mock<IArrQueueIterator>();
DownloadServiceFactory = new Mock<IDownloadServiceFactory>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = new Mock<IBlocklistProvider>();
TimeProvider = new FakeTimeProvider();
// Setup default behaviors
SetupDefaultBehaviors();
}
private void SetupDefaultBehaviors()
{
// EventPublisher methods return completed task by default
EventPublisher
.Setup(x => x.PublishAsync(
It.IsAny<Domain.Enums.EventType>(),
It.IsAny<string>(),
It.IsAny<Domain.Enums.EventSeverity>(),
It.IsAny<object?>(),
It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
}
/// <summary>
/// Creates a mock logger for a specific handler type
/// </summary>
public Mock<ILogger<T>> CreateLogger<T>() where T : GenericHandler
{
return new Mock<ILogger<T>>();
}
/// <summary>
/// Creates a mock download service
/// </summary>
public Mock<IDownloadService> CreateMockDownloadService(string clientName = "Test Client")
{
var mock = new Mock<IDownloadService>();
mock.Setup(x => x.ClientConfig).Returns(new Persistence.Models.Configuration.DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = clientName,
Type = Domain.Enums.DownloadClientType.Torrent,
TypeName = Domain.Enums.DownloadClientTypeName.qBittorrent,
Enabled = true,
Host = new Uri("http://localhost:8080")
});
mock.Setup(x => x.LoginAsync()).Returns(Task.CompletedTask);
return mock;
}
/// <summary>
/// Sets up the DownloadServiceFactory to return the specified mock services
/// </summary>
public void SetupDownloadServices(params Mock<IDownloadService>[] services)
{
foreach (var service in services)
{
DownloadServiceFactory
.Setup(x => x.GetDownloadService(service.Object.ClientConfig))
.Returns(service.Object);
}
}
/// <summary>
/// Creates a fresh DataContext, disposing the old one
/// </summary>
public DataContext RecreateDataContext(bool seedData = true)
{
DataContext?.Dispose();
DataContext = TestDataContextFactory.Create(seedData);
return DataContext;
}
public void ResetMocks()
{
MessageBus.Reset();
ArrClientFactory.Reset();
ArrQueueIterator.Reset();
DownloadServiceFactory.Reset();
EventPublisher.Reset();
BlocklistProvider.Reset();
Cache.Clear();
TimeProvider = new FakeTimeProvider();
SetupDefaultBehaviors();
}
public void Dispose()
{
DataContext?.Dispose();
Cache?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,335 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// Factory for creating SQLite in-memory DataContext instances for testing
/// </summary>
public static class TestDataContextFactory
{
/// <summary>
/// Creates a new SQLite in-memory DataContext with default seed data
/// </summary>
public static DataContext Create(bool seedData = true)
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connection)
.Options;
var context = new DataContext(options);
context.Database.EnsureCreated();
if (seedData)
{
SeedDefaultData(context);
}
return context;
}
/// <summary>
/// Seeds the minimum required data for GenericHandler.ExecuteAsync() to work
/// </summary>
private static void SeedDefaultData(DataContext context)
{
// General config
context.GeneralConfigs.Add(new GeneralConfig
{
Id = Guid.NewGuid(),
DryRun = false,
IgnoredDownloads = [],
Log = new LoggingConfig()
});
// Arr configs for all instance types
context.ArrConfigs.AddRange(
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
);
// Queue cleaner config
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
FailedImport = new FailedImportConfig()
});
// Content blocker config
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
DeleteKnownMalware = false,
DeletePrivate = false,
Sonarr = new BlocklistSettings { Enabled = false },
Radarr = new BlocklistSettings { Enabled = false },
Lidarr = new BlocklistSettings { Enabled = false },
Readarr = new BlocklistSettings { Enabled = false },
Whisparr = new BlocklistSettings { Enabled = false }
});
// Download cleaner config
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
Categories = [],
UnlinkedEnabled = false,
UnlinkedTargetCategory = "",
UnlinkedCategories = []
});
context.SaveChanges();
}
/// <summary>
/// Adds an enabled Sonarr instance to the context
/// </summary>
public static ArrInstance AddSonarrInstance(DataContext context, string url = "http://sonarr:8989", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Sonarr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled Radarr instance to the context
/// </summary>
public static ArrInstance AddRadarrInstance(DataContext context, string url = "http://radarr:7878", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Radarr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled Lidarr instance to the context
/// </summary>
public static ArrInstance AddLidarrInstance(DataContext context, string url = "http://lidarr:8686", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Lidarr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled Readarr instance to the context
/// </summary>
public static ArrInstance AddReadarrInstance(DataContext context, string url = "http://readarr:8787", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Readarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Readarr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled Whisparr instance to the context
/// </summary>
public static ArrInstance AddWhisparrInstance(DataContext context, string url = "http://whisparr:6969", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Whisparr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Whisparr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled download client to the context
/// </summary>
public static DownloadClientConfig AddDownloadClient(
DataContext context,
string name = "Test qBittorrent",
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
bool enabled = true)
{
var config = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = name,
TypeName = typeName,
Type = DownloadClientType.Torrent,
Enabled = enabled,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "admin"
};
context.DownloadClients.Add(config);
context.SaveChanges();
return config;
}
/// <summary>
/// Adds a stall rule to the context
/// </summary>
public static StallRule AddStallRule(
DataContext context,
string name = "Test Stall Rule",
bool enabled = true,
ushort minCompletionPercentage = 0,
ushort maxCompletionPercentage = 100,
int maxStrikes = 3)
{
var queueCleanerConfig = context.QueueCleanerConfigs.First();
var rule = new StallRule
{
Id = Guid.NewGuid(),
Name = name,
Enabled = enabled,
MinCompletionPercentage = minCompletionPercentage,
MaxCompletionPercentage = maxCompletionPercentage,
MaxStrikes = maxStrikes,
QueueCleanerConfigId = queueCleanerConfig.Id
};
context.StallRules.Add(rule);
context.SaveChanges();
return rule;
}
/// <summary>
/// Adds a slow rule to the context
/// </summary>
public static SlowRule AddSlowRule(
DataContext context,
string name = "Test Slow Rule",
bool enabled = true,
ushort minCompletionPercentage = 0,
ushort maxCompletionPercentage = 100,
int maxStrikes = 3,
string minSpeed = "1 KB/s")
{
var queueCleanerConfig = context.QueueCleanerConfigs.First();
var rule = new SlowRule
{
Id = Guid.NewGuid(),
Name = name,
Enabled = enabled,
MinCompletionPercentage = minCompletionPercentage,
MaxCompletionPercentage = maxCompletionPercentage,
MaxStrikes = maxStrikes,
MinSpeed = minSpeed,
QueueCleanerConfigId = queueCleanerConfig.Id
};
context.SlowRules.Add(rule);
context.SaveChanges();
return rule;
}
/// <summary>
/// Adds a clean category to the download cleaner config
/// </summary>
public static CleanCategory AddCleanCategory(
DataContext context,
string name = "completed",
double maxRatio = 1.0,
double minSeedTime = 1.0,
double maxSeedTime = -1)
{
var config = context.DownloadCleanerConfigs.Include(x => x.Categories).First();
var category = new CleanCategory
{
Id = Guid.NewGuid(),
Name = name,
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
DownloadCleanerConfigId = config.Id
};
config.Categories.Add(category);
context.CleanCategories.Add(category);
context.SaveChanges();
return category;
}
}

View File

@@ -0,0 +1,187 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.MalwareBlocker;
public class BlocklistProviderTests : IDisposable
{
private readonly IMemoryCache _cache;
private readonly ILogger<BlocklistProvider> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly BlocklistProvider _provider;
public BlocklistProviderTests()
{
_cache = new MemoryCache(new MemoryCacheOptions());
_logger = Substitute.For<ILogger<BlocklistProvider>>();
_scopeFactory = Substitute.For<IServiceScopeFactory>();
_provider = new BlocklistProvider(_logger, _scopeFactory, _cache);
}
public void Dispose()
{
_cache.Dispose();
}
[Fact]
public void GetBlocklistType_NotInCache_ReturnsDefaultBlacklist()
{
// Act
var result = _provider.GetBlocklistType(InstanceType.Sonarr);
// Assert
result.ShouldBe(BlocklistType.Blacklist);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public void GetBlocklistType_InCache_ReturnsCachedValue(InstanceType instanceType)
{
// Arrange
_cache.Set(CacheKeys.BlocklistType(instanceType), BlocklistType.Whitelist);
// Act
var result = _provider.GetBlocklistType(instanceType);
// Assert
result.ShouldBe(BlocklistType.Whitelist);
}
[Fact]
public void GetPatterns_NotInCache_ReturnsEmptyBag()
{
// Act
var result = _provider.GetPatterns(InstanceType.Sonarr);
// Assert
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
[Fact]
public void GetPatterns_InCache_ReturnsCachedPatterns()
{
// Arrange
var patterns = new ConcurrentBag<string> { "*.exe", "*.dll", "malware*" };
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Radarr), patterns);
// Act
var result = _provider.GetPatterns(InstanceType.Radarr);
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("*.exe");
result.ShouldContain("*.dll");
result.ShouldContain("malware*");
}
[Fact]
public void GetRegexes_NotInCache_ReturnsEmptyBag()
{
// Act
var result = _provider.GetRegexes(InstanceType.Lidarr);
// Assert
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
[Fact]
public void GetRegexes_InCache_ReturnsCachedRegexes()
{
// Arrange
var regexes = new ConcurrentBag<Regex>
{
new Regex(@"^\d+$"),
new Regex(@"test\d+\.exe")
};
_cache.Set(CacheKeys.BlocklistRegexes(InstanceType.Readarr), regexes);
// Act
var result = _provider.GetRegexes(InstanceType.Readarr);
// Assert
result.Count.ShouldBe(2);
}
[Fact]
public void GetMalwarePatterns_NotInCache_ReturnsEmptyBag()
{
// Act
var result = _provider.GetMalwarePatterns();
// Assert
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
[Fact]
public void GetMalwarePatterns_InCache_ReturnsCachedPatterns()
{
// Arrange
var patterns = new ConcurrentBag<string> { "known_malware.exe", "trojan*", "virus.dll" };
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
// Act
var result = _provider.GetMalwarePatterns();
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("known_malware.exe");
result.ShouldContain("trojan*");
result.ShouldContain("virus.dll");
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public void GetPatterns_DifferentInstanceTypes_UsesCorrectCacheKey(InstanceType instanceType)
{
// Arrange - set patterns for each instance type differently
var patterns = new ConcurrentBag<string> { $"pattern_for_{instanceType}" };
_cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns);
// Act
var result = _provider.GetPatterns(instanceType);
// Assert
result.ShouldContain($"pattern_for_{instanceType}");
}
[Fact]
public void GetPatterns_DifferentInstanceTypes_ReturnsDifferentPatterns()
{
// Arrange
var sonarrPatterns = new ConcurrentBag<string> { "sonarr_pattern" };
var radarrPatterns = new ConcurrentBag<string> { "radarr_pattern" };
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Sonarr), sonarrPatterns);
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Radarr), radarrPatterns);
// Act
var sonarrResult = _provider.GetPatterns(InstanceType.Sonarr);
var radarrResult = _provider.GetPatterns(InstanceType.Radarr);
// Assert
sonarrResult.ShouldContain("sonarr_pattern");
sonarrResult.ShouldNotContain("radarr_pattern");
radarrResult.ShouldContain("radarr_pattern");
radarrResult.ShouldNotContain("sonarr_pattern");
}
}

View File

@@ -0,0 +1,291 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise;
public class AppriseProxyTests
{
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public AppriseProxyTests()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private AppriseProxy CreateProxy()
{
return new AppriseProxy(_httpClientFactoryMock.Object);
}
private static ApprisePayload CreatePayload()
{
return new ApprisePayload
{
Title = "Test Title",
Body = "Test Body"
};
}
private static AppriseConfig CreateConfig()
{
return new AppriseConfig
{
Url = "http://apprise.local",
Key = "test-key"
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidFactory_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_BuildsCorrectUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new AppriseConfig { Url = "http://apprise.local", Key = "my-key" };
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("/notify/my-key", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_WithBasicAuth_SetsAuthorizationHeader()
{
// Arrange
var proxy = CreateProxy();
string? capturedAuthHeader = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedAuthHeader = req.Headers.Authorization?.Scheme)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new AppriseConfig { Url = "http://user:pass@apprise.local", Key = "test-key" };
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.Equal("Basic", capturedAuthHeader);
}
#endregion
#region SendNotification Error Tests
[Fact]
public async Task SendNotification_When401_ThrowsAppriseExceptionWithInvalidApiKey()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.Unauthorized);
// Act & Assert
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("API key is invalid", ex.Message);
}
[Fact]
public async Task SendNotification_When424_ThrowsAppriseExceptionWithTagsError()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse((HttpStatusCode)424);
// Act & Assert
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("tags", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
public async Task SendNotification_WhenServiceUnavailable_ThrowsAppriseException(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsAppriseException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Unable to send notification", ex.Message);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsAppriseException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Unable to send notification", ex.Message);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -0,0 +1,203 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class AppriseProviderTests
{
private readonly Mock<IAppriseProxy> _proxyMock;
private readonly AppriseConfig _config;
private readonly AppriseProvider _provider;
public AppriseProviderTests()
{
_proxyMock = new Mock<IAppriseProxy>();
_config = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://apprise.example.com",
Key = "testkey",
Tags = "tag1,tag2"
};
_provider = new AppriseProvider(
"TestApprise",
NotificationProviderType.Apprise,
_config,
_proxyMock.Object);
}
#region Constructor Tests
[Fact]
public void Constructor_SetsNameCorrectly()
{
// Assert
Assert.Equal("TestApprise", _provider.Name);
}
[Fact]
public void Constructor_SetsTypeCorrectly()
{
// Assert
Assert.Equal(NotificationProviderType.Apprise, _provider.Type);
}
#endregion
#region SendNotificationAsync Tests
[Fact]
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
{
// Arrange
var context = CreateTestContext();
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal(context.Title, capturedPayload.Title);
Assert.Contains(context.Description, capturedPayload.Body);
}
[Fact]
public async Task SendNotificationAsync_IncludesDataInBody()
{
// Arrange
var context = CreateTestContext();
context.Data["TestKey"] = "TestValue";
context.Data["AnotherKey"] = "AnotherValue";
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("TestKey: TestValue", capturedPayload.Body);
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Body);
}
[Theory]
[InlineData(EventSeverity.Information, "info")]
[InlineData(EventSeverity.Warning, "warning")]
[InlineData(EventSeverity.Important, "failure")]
public async Task SendNotificationAsync_MapsEventSeverityToCorrectType(EventSeverity severity, string expectedType)
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Notification",
Description = "Test Description",
Severity = severity,
Data = new Dictionary<string, string>()
};
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal(expectedType, capturedPayload.Type);
}
[Fact]
public async Task SendNotificationAsync_IncludesTagsFromConfig()
{
// Arrange
var context = CreateTestContext();
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("tag1,tag2", capturedPayload.Tags);
}
[Fact]
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
{
// Arrange
var context = CreateTestContext();
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.ThrowsAsync(new Exception("Proxy error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
}
[Fact]
public async Task SendNotificationAsync_WithEmptyData_StillIncludesDescription()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = "Test Title",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("Test Description", capturedPayload.Body);
}
#endregion
#region Helper Methods
private static NotificationContext CreateTestContext()
{
return new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Notification",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
}
#endregion
}

View File

@@ -0,0 +1,283 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Notifiarr;
public class NotifiarrProxyTests
{
private readonly Mock<ILogger<NotifiarrProxy>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public NotifiarrProxyTests()
{
_loggerMock = new Mock<ILogger<NotifiarrProxy>>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private NotifiarrProxy CreateProxy()
{
return new NotifiarrProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
}
private static NotifiarrPayload CreatePayload()
{
return new NotifiarrPayload
{
Notification = new NotifiarrNotification { Update = false },
Discord = new Discord
{
Color = "#FF0000",
Text = new Text { Title = "Test", Content = "Test content" },
Ids = new Ids { Channel = "123456789" }
}
};
}
private static NotifiarrConfig CreateConfig()
{
return new NotifiarrConfig
{
ApiKey = "test-api-key-12345",
ChannelId = "123456789"
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_BuildsCorrectUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new NotifiarrConfig { ApiKey = "my-api-key", ChannelId = "123" };
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("notifiarr.com", capturedUri.ToString());
Assert.Contains("passthrough", capturedUri.ToString());
Assert.Contains("my-api-key", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_LogsTraceWithContent()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region SendNotification Error Tests
[Fact]
public async Task SendNotification_When401_ThrowsNotifiarrExceptionWithInvalidApiKey()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.Unauthorized);
// Act & Assert
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("API key is invalid", ex.Message);
}
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
public async Task SendNotification_WhenServiceUnavailable_ThrowsNotifiarrException(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsNotifiarrException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsNotifiarrException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -0,0 +1,267 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotifiarrProviderTests
{
private readonly Mock<INotifiarrProxy> _proxyMock;
private readonly NotifiarrConfig _config;
private readonly NotifiarrProvider _provider;
public NotifiarrProviderTests()
{
_proxyMock = new Mock<INotifiarrProxy>();
_config = new NotifiarrConfig
{
Id = Guid.NewGuid(),
ApiKey = "testapikey1234567890",
ChannelId = "123456789012345678"
};
_provider = new NotifiarrProvider(
"TestNotifiarr",
NotificationProviderType.Notifiarr,
_config,
_proxyMock.Object);
}
#region Constructor Tests
[Fact]
public void Constructor_SetsNameCorrectly()
{
// Assert
Assert.Equal("TestNotifiarr", _provider.Name);
}
[Fact]
public void Constructor_SetsTypeCorrectly()
{
// Assert
Assert.Equal(NotificationProviderType.Notifiarr, _provider.Type);
}
#endregion
#region SendNotificationAsync Tests
[Fact]
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
{
// Arrange
var context = CreateTestContext();
NotifiarrPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.NotNull(capturedPayload.Discord);
Assert.Equal(context.Title, capturedPayload.Discord.Text.Title);
Assert.Equal(context.Description, capturedPayload.Discord.Text.Description);
}
[Fact]
public async Task SendNotificationAsync_UsesConfiguredChannelId()
{
// Arrange
var context = CreateTestContext();
NotifiarrPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("123456789012345678", capturedPayload.Discord.Ids.Channel);
}
[Fact]
public async Task SendNotificationAsync_IncludesDataAsFields()
{
// Arrange
var context = CreateTestContext();
context.Data["TestKey"] = "TestValue";
context.Data["AnotherKey"] = "AnotherValue";
NotifiarrPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal(2, capturedPayload.Discord.Text.Fields.Count);
Assert.Contains(capturedPayload.Discord.Text.Fields, f => f.Title == "TestKey" && f.Text == "TestValue");
Assert.Contains(capturedPayload.Discord.Text.Fields, f => f.Title == "AnotherKey" && f.Text == "AnotherValue");
}
[Theory]
[InlineData(EventSeverity.Information, "28a745")] // Green
[InlineData(EventSeverity.Warning, "f0ad4e")] // Orange
[InlineData(EventSeverity.Important, "bb2124")] // Red
public async Task SendNotificationAsync_MapsEventSeverityToCorrectColor(EventSeverity severity, string expectedColor)
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Notification",
Description = "Test Description",
Severity = severity,
Data = new Dictionary<string, string>()
};
NotifiarrPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal(expectedColor, capturedPayload.Discord.Color);
}
[Fact]
public async Task SendNotificationAsync_IncludesCleanuperrLogo()
{
// Arrange
var context = CreateTestContext();
NotifiarrPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("Cleanuparr", capturedPayload.Discord.Text.Icon);
Assert.NotNull(capturedPayload.Discord.Images.Thumbnail);
Assert.Contains("Cleanuparr", capturedPayload.Discord.Images.Thumbnail.ToString());
}
[Fact]
public async Task SendNotificationAsync_IncludesContextImage()
{
// Arrange
var context = CreateTestContext();
context.Image = new Uri("https://example.com/image.jpg");
NotifiarrPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal(new Uri("https://example.com/image.jpg"), capturedPayload.Discord.Images.Image);
}
[Fact]
public async Task SendNotificationAsync_WhenNoImage_ImagesImageIsNull()
{
// Arrange
var context = CreateTestContext();
context.Image = null;
NotifiarrPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Null(capturedPayload.Discord.Images.Image);
}
[Fact]
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
{
// Arrange
var context = CreateTestContext();
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.ThrowsAsync(new Exception("Proxy error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
}
[Fact]
public async Task SendNotificationAsync_WithEmptyData_HasEmptyFields()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = "Test Title",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
NotifiarrPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Empty(capturedPayload.Discord.Text.Fields);
}
#endregion
#region Helper Methods
private static NotificationContext CreateTestContext()
{
return new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Notification",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
}
#endregion
}

View File

@@ -0,0 +1,345 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationConfigurationServiceTests : IDisposable
{
private readonly DataContext _context;
private readonly Mock<ILogger<NotificationConfigurationService>> _loggerMock;
private readonly NotificationConfigurationService _service;
public NotificationConfigurationServiceTests()
{
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DataContext(options);
_loggerMock = new Mock<ILogger<NotificationConfigurationService>>();
_service = new NotificationConfigurationService(_context, _loggerMock.Object);
}
public void Dispose()
{
_context.Database.EnsureDeleted();
_context.Dispose();
}
#region GetActiveProvidersAsync Tests
[Fact]
public async Task GetActiveProvidersAsync_NoProviders_ReturnsEmptyList()
{
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetActiveProvidersAsync_WithEnabledProvider_ReturnsProvider()
{
// Arrange
var config = CreateNotifiarrConfig("Test Provider", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Single(result);
Assert.Equal("Test Provider", result[0].Name);
}
[Fact]
public async Task GetActiveProvidersAsync_WithDisabledProvider_ReturnsEmptyList()
{
// Arrange
var config = CreateNotifiarrConfig("Disabled Provider", isEnabled: false);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetActiveProvidersAsync_CachesResults()
{
// Arrange
var config = CreateNotifiarrConfig("Test Provider", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act - Call twice
var result1 = await _service.GetActiveProvidersAsync();
var result2 = await _service.GetActiveProvidersAsync();
// Assert - Both calls should return same data
Assert.Single(result1);
Assert.Single(result2);
Assert.Equal(result1[0].Id, result2[0].Id);
}
[Fact]
public async Task GetActiveProvidersAsync_WithMixedProviders_ReturnsOnlyEnabled()
{
// Arrange
var enabledConfig = CreateNotifiarrConfig("Enabled", isEnabled: true);
var disabledConfig = CreateNotifiarrConfig("Disabled", isEnabled: false);
_context.Set<NotificationConfig>().AddRange(enabledConfig, disabledConfig);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Single(result);
Assert.Equal("Enabled", result[0].Name);
}
#endregion
#region GetProvidersForEventAsync Tests
[Fact]
public async Task GetProvidersForEventAsync_NoMatchingProviders_ReturnsEmptyList()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: false);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetProvidersForEventAsync_WithMatchingProvider_ReturnsProvider()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
// Assert
Assert.Single(result);
}
[Fact]
public async Task GetProvidersForEventAsync_TestEvent_AlwaysReturnsEnabledProviders()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: false);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(NotificationEventType.Test);
// Assert
Assert.Single(result);
}
[Theory]
[InlineData(NotificationEventType.FailedImportStrike, true, false, false, false, false, false)]
[InlineData(NotificationEventType.StalledStrike, false, true, false, false, false, false)]
[InlineData(NotificationEventType.SlowSpeedStrike, false, false, true, false, false, false)]
[InlineData(NotificationEventType.SlowTimeStrike, false, false, true, false, false, false)]
[InlineData(NotificationEventType.QueueItemDeleted, false, false, false, true, false, false)]
[InlineData(NotificationEventType.DownloadCleaned, false, false, false, false, true, false)]
[InlineData(NotificationEventType.CategoryChanged, false, false, false, false, false, true)]
public async Task GetProvidersForEventAsync_ReturnsProviderForCorrectEvents(
NotificationEventType eventType,
bool onFailedImport, bool onStalled, bool onSlow,
bool onDeleted, bool onCleaned, bool onCategory)
{
// Arrange
var config = new NotificationConfig
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
OnFailedImportStrike = onFailedImport,
OnStalledStrike = onStalled,
OnSlowStrike = onSlow,
OnQueueItemDeleted = onDeleted,
OnDownloadCleaned = onCleaned,
OnCategoryChanged = onCategory,
NotifiarrConfiguration = new NotifiarrConfig
{
Id = Guid.NewGuid(),
ApiKey = "testapikey1234567890",
ChannelId = "123456789012345678"
}
};
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(eventType);
// Assert
Assert.Single(result);
}
#endregion
#region GetProviderByIdAsync Tests
[Fact]
public async Task GetProviderByIdAsync_ProviderExists_ReturnsProvider()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProviderByIdAsync(config.Id);
// Assert
Assert.NotNull(result);
Assert.Equal(config.Id, result.Id);
Assert.Equal("Test", result.Name);
}
[Fact]
public async Task GetProviderByIdAsync_ProviderDoesNotExist_ReturnsNull()
{
// Act
var result = await _service.GetProviderByIdAsync(Guid.NewGuid());
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetProviderByIdAsync_DisabledProvider_ReturnsNull()
{
// Arrange
var config = CreateNotifiarrConfig("Disabled", isEnabled: false);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProviderByIdAsync(config.Id);
// Assert
Assert.Null(result);
}
#endregion
#region InvalidateCacheAsync Tests
[Fact]
public async Task InvalidateCacheAsync_RefreshesDataOnNextCall()
{
// Arrange
var config1 = CreateNotifiarrConfig("Provider 1", isEnabled: true);
_context.Set<NotificationConfig>().Add(config1);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// First call to populate cache
var result1 = await _service.GetActiveProvidersAsync();
Assert.Single(result1);
// Add another provider
var config2 = CreateNotifiarrConfig("Provider 2", isEnabled: true);
_context.Set<NotificationConfig>().Add(config2);
await _context.SaveChangesAsync();
// Without invalidation, should return cached result
var result2 = await _service.GetActiveProvidersAsync();
Assert.Single(result2);
// After invalidation, should return updated result
await _service.InvalidateCacheAsync();
var result3 = await _service.GetActiveProvidersAsync();
Assert.Equal(2, result3.Count);
}
[Fact]
public async Task InvalidateCacheAsync_LogsDebugMessage()
{
// Act
await _service.InvalidateCacheAsync();
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("cache invalidated")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region Helper Methods
private static NotificationConfig CreateNotifiarrConfig(
string name,
bool isEnabled,
bool onStalledStrike = true,
bool onFailedImport = true,
bool onSlow = true,
bool onDeleted = true,
bool onCleaned = true,
bool onCategory = true)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Notifiarr,
IsEnabled = isEnabled,
OnStalledStrike = onStalledStrike,
OnFailedImportStrike = onFailedImport,
OnSlowStrike = onSlow,
OnQueueItemDeleted = onDeleted,
OnDownloadCleaned = onCleaned,
OnCategoryChanged = onCategory,
NotifiarrConfiguration = new NotifiarrConfig
{
Id = Guid.NewGuid(),
ApiKey = "testapikey1234567890",
ChannelId = "123456789012345678"
}
};
}
#endregion
}

View File

@@ -0,0 +1,559 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using MassTransit;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationConsumerTests
{
private readonly Mock<ILogger<NotificationService>> _serviceLoggerMock;
private readonly Mock<INotificationConfigurationService> _configurationServiceMock;
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
private readonly NotificationService _notificationService;
private readonly FakeTimeProvider _timeProvider;
public NotificationConsumerTests()
{
_serviceLoggerMock = new Mock<ILogger<NotificationService>>();
_configurationServiceMock = new Mock<INotificationConfigurationService>();
_providerFactoryMock = new Mock<INotificationProviderFactory>();
_timeProvider = new FakeTimeProvider();
_notificationService = new NotificationService(
_serviceLoggerMock.Object,
_configurationServiceMock.Object,
_providerFactoryMock.Object);
}
#region Consume Tests - FailedImportStrikeNotification
[Fact]
public async Task Consume_FailedImportStrikeNotification_SendsCorrectEventType()
{
// Arrange
var consumer = CreateConsumer<FailedImportStrikeNotification>();
var notification = new FailedImportStrikeNotification
{
Title = "Test Failed Import",
Description = "Test Description",
Level = NotificationLevel.Warning,
InstanceType = InstanceType.Radarr,
InstanceUrl = new Uri("http://radarr.local"),
Hash = "TEST123"
};
var contextMock = CreateConsumeContextMock(notification);
NotificationEventType? capturedEventType = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.Callback<NotificationEventType>(e => capturedEventType = e)
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.Equal(NotificationEventType.FailedImportStrike, capturedEventType);
}
#endregion
#region Consume Tests - StalledStrikeNotification
[Fact]
public async Task Consume_StalledStrikeNotification_SendsCorrectEventType()
{
// Arrange
var consumer = CreateConsumer<StalledStrikeNotification>();
var notification = new StalledStrikeNotification
{
Title = "Test Stalled",
Description = "Stalled Description",
Level = NotificationLevel.Important,
InstanceType = InstanceType.Sonarr,
InstanceUrl = new Uri("http://sonarr.local"),
Hash = "STALL123"
};
var contextMock = CreateConsumeContextMock(notification);
NotificationEventType? capturedEventType = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.Callback<NotificationEventType>(e => capturedEventType = e)
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.Equal(NotificationEventType.StalledStrike, capturedEventType);
}
#endregion
#region Consume Tests - SlowSpeedStrikeNotification
[Fact]
public async Task Consume_SlowSpeedStrikeNotification_SendsCorrectEventType()
{
// Arrange
var consumer = CreateConsumer<SlowSpeedStrikeNotification>();
var notification = new SlowSpeedStrikeNotification
{
Title = "Slow Speed",
Description = "Download too slow",
Level = NotificationLevel.Warning,
InstanceType = InstanceType.Radarr,
InstanceUrl = new Uri("http://radarr.local"),
Hash = "SLOW123"
};
var contextMock = CreateConsumeContextMock(notification);
NotificationEventType? capturedEventType = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.Callback<NotificationEventType>(e => capturedEventType = e)
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.Equal(NotificationEventType.SlowSpeedStrike, capturedEventType);
}
#endregion
#region Consume Tests - SlowTimeStrikeNotification
[Fact]
public async Task Consume_SlowTimeStrikeNotification_SendsCorrectEventType()
{
// Arrange
var consumer = CreateConsumer<SlowTimeStrikeNotification>();
var notification = new SlowTimeStrikeNotification
{
Title = "Slow Time",
Description = "Download taking too long",
Level = NotificationLevel.Warning,
InstanceType = InstanceType.Radarr,
InstanceUrl = new Uri("http://radarr.local"),
Hash = "TIME123"
};
var contextMock = CreateConsumeContextMock(notification);
NotificationEventType? capturedEventType = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.Callback<NotificationEventType>(e => capturedEventType = e)
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.Equal(NotificationEventType.SlowTimeStrike, capturedEventType);
}
#endregion
#region Consume Tests - QueueItemDeletedNotification
[Fact]
public async Task Consume_QueueItemDeletedNotification_SendsCorrectEventType()
{
// Arrange
var consumer = CreateConsumer<QueueItemDeletedNotification>();
var notification = new QueueItemDeletedNotification
{
Title = "Item Deleted",
Description = "Queue item removed",
Level = NotificationLevel.Important,
InstanceType = InstanceType.Lidarr,
InstanceUrl = new Uri("http://lidarr.local"),
Hash = "DEL123"
};
var contextMock = CreateConsumeContextMock(notification);
NotificationEventType? capturedEventType = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.Callback<NotificationEventType>(e => capturedEventType = e)
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.Equal(NotificationEventType.QueueItemDeleted, capturedEventType);
}
#endregion
#region Consume Tests - DownloadCleanedNotification
[Fact]
public async Task Consume_DownloadCleanedNotification_SendsCorrectEventType()
{
// Arrange
var consumer = CreateConsumer<DownloadCleanedNotification>();
var notification = new DownloadCleanedNotification
{
Title = "Download Cleaned",
Description = "Old download removed",
Level = NotificationLevel.Information
};
var contextMock = CreateConsumeContextMock(notification);
NotificationEventType? capturedEventType = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.Callback<NotificationEventType>(e => capturedEventType = e)
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.Equal(NotificationEventType.DownloadCleaned, capturedEventType);
}
#endregion
#region Consume Tests - CategoryChangedNotification
[Fact]
public async Task Consume_CategoryChangedNotification_SendsCorrectEventType()
{
// Arrange
var consumer = CreateConsumer<CategoryChangedNotification>();
var notification = new CategoryChangedNotification
{
Title = "Category Changed",
Description = "Category updated",
Level = NotificationLevel.Information
};
var contextMock = CreateConsumeContextMock(notification);
NotificationEventType? capturedEventType = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.Callback<NotificationEventType>(e => capturedEventType = e)
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.Equal(NotificationEventType.CategoryChanged, capturedEventType);
}
#endregion
#region NotificationContext Conversion Tests
[Theory]
[InlineData(NotificationLevel.Information, EventSeverity.Information)]
[InlineData(NotificationLevel.Warning, EventSeverity.Warning)]
[InlineData(NotificationLevel.Important, EventSeverity.Important)]
public async Task Consume_MapsNotificationLevelToSeverity(NotificationLevel level, EventSeverity expectedSeverity)
{
// Arrange
var consumer = CreateConsumer<FailedImportStrikeNotification>();
var notification = new FailedImportStrikeNotification
{
Title = "Test",
Description = "Test",
Level = level,
InstanceType = InstanceType.Radarr,
InstanceUrl = new Uri("http://radarr.local"),
Hash = "LEVEL123"
};
var contextMock = CreateConsumeContextMock(notification);
NotificationContext? capturedContext = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.Callback<NotificationContext>(c => capturedContext = c)
.Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal(expectedSeverity, capturedContext.Severity);
}
[Fact]
public async Task Consume_ArrNotification_IncludesArrDataInContext()
{
// Arrange
var consumer = CreateConsumer<FailedImportStrikeNotification>();
var notification = new FailedImportStrikeNotification
{
Title = "Test",
Description = "Test",
Level = NotificationLevel.Warning,
InstanceType = InstanceType.Sonarr,
InstanceUrl = new Uri("http://sonarr.local"),
Hash = "ABC123",
Image = new Uri("http://example.com/image.jpg")
};
var contextMock = CreateConsumeContextMock(notification);
NotificationContext? capturedContext = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.Callback<NotificationContext>(c => capturedContext = c)
.Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal("Sonarr", capturedContext.Data["Instance type"]);
Assert.Equal("http://sonarr.local/", capturedContext.Data["Url"]);
Assert.Equal("ABC123", capturedContext.Data["Hash"]);
Assert.Equal(new Uri("http://example.com/image.jpg"), capturedContext.Image);
}
[Fact]
public async Task Consume_WithCustomFields_IncludesFieldsInContext()
{
// Arrange
var consumer = CreateConsumer<FailedImportStrikeNotification>();
var notification = new FailedImportStrikeNotification
{
Title = "Test",
Description = "Test",
Level = NotificationLevel.Warning,
InstanceType = InstanceType.Radarr,
InstanceUrl = new Uri("http://radarr.local"),
Hash = "XYZ789",
Fields = new List<NotificationField>
{
new() { Key = "CustomKey1", Value = "CustomValue1" },
new() { Key = "CustomKey2", Value = "CustomValue2" }
}
};
var contextMock = CreateConsumeContextMock(notification);
NotificationContext? capturedContext = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.Callback<NotificationContext>(c => capturedContext = c)
.Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal("CustomValue1", capturedContext.Data["CustomKey1"]);
Assert.Equal("CustomValue2", capturedContext.Data["CustomKey2"]);
}
[Fact]
public async Task Consume_NonArrNotification_DoesNotIncludeArrData()
{
// Arrange
var consumer = CreateConsumer<DownloadCleanedNotification>();
var notification = new DownloadCleanedNotification
{
Title = "Download Cleaned",
Description = "Test",
Level = NotificationLevel.Information
};
var contextMock = CreateConsumeContextMock(notification);
NotificationContext? capturedContext = null;
var providerMock = new Mock<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
.Returns(providerMock.Object);
providerMock
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.Callback<NotificationContext>(c => capturedContext = c)
.Returns(Task.CompletedTask);
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
Assert.NotNull(capturedContext);
Assert.False(capturedContext.Data.ContainsKey("Instance type"));
Assert.False(capturedContext.Data.ContainsKey("Url"));
Assert.False(capturedContext.Data.ContainsKey("Hash"));
}
#endregion
#region No Providers Configured Tests
[Fact]
public async Task Consume_WhenNoProvidersConfigured_DoesNotSendNotification()
{
// Arrange
var consumer = CreateConsumer<FailedImportStrikeNotification>();
var notification = new FailedImportStrikeNotification
{
Title = "Test",
Description = "Test",
Level = NotificationLevel.Warning,
InstanceType = InstanceType.Radarr,
InstanceUrl = new Uri("http://radarr.local"),
Hash = "NOPROV123"
};
var contextMock = CreateConsumeContextMock(notification);
_configurationServiceMock
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto>());
// Act
await ConsumeWithTimeAdvance(consumer, contextMock);
// Assert
_providerFactoryMock.Verify(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()), Times.Never);
}
#endregion
#region Helper Methods
private NotificationConsumer<T> CreateConsumer<T>() where T : Notification
{
var loggerMock = new Mock<ILogger<NotificationConsumer<T>>>();
return new NotificationConsumer<T>(loggerMock.Object, _notificationService, _timeProvider);
}
private static Mock<ConsumeContext<T>> CreateConsumeContextMock<T>(T message) where T : class
{
var mock = new Mock<ConsumeContext<T>>();
mock.Setup(c => c.Message).Returns(message);
return mock;
}
/// <summary>
/// Executes the consumer and advances time past the 1-second spam prevention delay
/// </summary>
private async Task ConsumeWithTimeAdvance<T>(NotificationConsumer<T> consumer, Mock<ConsumeContext<T>> contextMock) where T : Notification
{
var task = consumer.Consume(contextMock.Object);
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await task;
}
#endregion
}

View File

@@ -0,0 +1,257 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationProviderFactoryTests
{
private readonly Mock<IAppriseProxy> _appriseProxyMock;
private readonly Mock<INtfyProxy> _ntfyProxyMock;
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly IServiceProvider _serviceProvider;
private readonly NotificationProviderFactory _factory;
public NotificationProviderFactoryTests()
{
_appriseProxyMock = new Mock<IAppriseProxy>();
_ntfyProxyMock = new Mock<INtfyProxy>();
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
services.AddSingleton(_ntfyProxyMock.Object);
services.AddSingleton(_notifiarrProxyMock.Object);
_serviceProvider = services.BuildServiceProvider();
_factory = new NotificationProviderFactory(_serviceProvider);
}
#region CreateProvider Tests
[Fact]
public void CreateProvider_AppriseType_CreatesAppriseProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestApprise",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Configuration = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://apprise.example.com",
Key = "testkey"
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<AppriseProvider>(provider);
Assert.Equal("TestApprise", provider.Name);
Assert.Equal(NotificationProviderType.Apprise, provider.Type);
}
[Fact]
public void CreateProvider_NtfyType_CreatesNtfyProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestNtfy",
Type = NotificationProviderType.Ntfy,
IsEnabled = true,
Configuration = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { "test-topic" },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.Default
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<NtfyProvider>(provider);
Assert.Equal("TestNtfy", provider.Name);
Assert.Equal(NotificationProviderType.Ntfy, provider.Type);
}
[Fact]
public void CreateProvider_NotifiarrType_CreatesNotifiarrProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestNotifiarr",
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
Configuration = new NotifiarrConfig
{
Id = Guid.NewGuid(),
ApiKey = "testapikey1234567890",
ChannelId = "123456789012345678"
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<NotifiarrProvider>(provider);
Assert.Equal("TestNotifiarr", provider.Name);
Assert.Equal(NotificationProviderType.Notifiarr, provider.Type);
}
[Fact]
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestUnsupported",
Type = (NotificationProviderType)999, // Invalid type
IsEnabled = true,
Configuration = new object()
};
// Act & Assert
var exception = Assert.Throws<NotSupportedException>(() => _factory.CreateProvider(config));
Assert.Contains("not supported", exception.Message);
}
[Fact]
public void CreateProvider_AppriseType_UsesCorrectProxy()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestApprise",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Configuration = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://apprise.example.com",
Key = "testkey"
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert - provider was created with the injected proxy
Assert.NotNull(provider);
// The proxy would be used when SendNotificationAsync is called
}
[Fact]
public void CreateProvider_PreservesProviderName()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "My Custom Provider Name",
Type = NotificationProviderType.Ntfy,
IsEnabled = true,
Configuration = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { "test" },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.Default
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.Equal("My Custom Provider Name", provider.Name);
}
[Fact]
public void CreateProvider_PreservesProviderType()
{
// Arrange
var configs = new[]
{
(Type: NotificationProviderType.Apprise, Config: (object)new AppriseConfig { Id = Guid.NewGuid(), Url = "http://test.com", Key = "key" }),
(Type: NotificationProviderType.Ntfy, Config: (object)new NtfyConfig { Id = Guid.NewGuid(), ServerUrl = "http://test.com", Topics = new List<string> { "t" }, AuthenticationType = NtfyAuthenticationType.None, Priority = NtfyPriority.Default }),
(Type: NotificationProviderType.Notifiarr, Config: (object)new NotifiarrConfig { Id = Guid.NewGuid(), ApiKey = "1234567890", ChannelId = "12345" })
};
foreach (var (type, configObj) in configs)
{
var dto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = $"Test-{type}",
Type = type,
IsEnabled = true,
Configuration = configObj
};
// Act
var provider = _factory.CreateProvider(dto);
// Assert
Assert.Equal(type, provider.Type);
}
}
#endregion
#region Service Resolution Tests
[Fact]
public void CreateProvider_WhenProxyNotRegistered_ThrowsException()
{
// Arrange - create a service provider without the proxy
var emptyServices = new ServiceCollection();
var emptyServiceProvider = emptyServices.BuildServiceProvider();
var factoryWithNoServices = new NotificationProviderFactory(emptyServiceProvider);
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestApprise",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Configuration = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://test.com",
Key = "key"
}
};
// Act & Assert
Assert.Throws<InvalidOperationException>(() => factoryWithNoServices.CreateProvider(config));
}
#endregion
}

View File

@@ -0,0 +1,597 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationPublisherTests
{
private readonly Mock<ILogger<NotificationPublisher>> _loggerMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<INotificationConfigurationService> _configServiceMock;
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
private readonly NotificationPublisher _publisher;
public NotificationPublisherTests()
{
_loggerMock = new Mock<ILogger<NotificationPublisher>>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_configServiceMock = new Mock<INotificationConfigurationService>();
_providerFactoryMock = new Mock<INotificationProviderFactory>();
// Setup dry run interceptor to call through
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns<Delegate, object[]>(async (action, parameters) =>
{
if (action is Func<(NotificationEventType, NotificationContext), Task> func && parameters.Length > 0)
{
var param = ((NotificationEventType, NotificationContext))parameters[0];
await func(param);
}
});
_publisher = new NotificationPublisher(
_loggerMock.Object,
_dryRunInterceptorMock.Object,
_configServiceMock.Object,
_providerFactoryMock.Object);
}
private void SetupContext(InstanceType instanceType = InstanceType.Sonarr)
{
var record = new QueueRecord
{
Id = 1,
Title = "Test Show",
DownloadId = "ABCD1234",
Status = "Downloading",
Protocol = "torrent"
};
ContextProvider.Set(nameof(QueueRecord), record);
ContextProvider.Set(nameof(InstanceType), (object)instanceType);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://sonarr.local"));
}
private void SetupDownloadCleanerContext()
{
ContextProvider.Set("downloadName", "Test Download");
ContextProvider.Set("hash", "HASH123");
}
#region Constructor Tests
[Fact]
public void Constructor_SetsAllDependencies()
{
// Assert
Assert.NotNull(_publisher);
}
#endregion
#region NotifyStrike Tests
[Fact]
public async Task NotifyStrike_WithStalledStrike_SendsNotification()
{
// Arrange
SetupContext();
var rule = new StallRule { Name = "Test Rule" };
ContextProvider.Set<QueueRule>(rule);
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.StalledStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyStrike(StrikeType.Stalled, 1);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.StalledStrike &&
c.Data.ContainsKey("Strike type") &&
c.Data["Strike type"] == "Stalled")), Times.Once);
}
[Fact]
public async Task NotifyStrike_WithFailedImportStrike_MapsToCorrectEventType()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 2);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.FailedImportStrike &&
c.Data["Strike count"] == "2")), Times.Once);
}
[Theory]
[InlineData(StrikeType.Stalled, NotificationEventType.StalledStrike)]
[InlineData(StrikeType.DownloadingMetadata, NotificationEventType.StalledStrike)]
[InlineData(StrikeType.FailedImport, NotificationEventType.FailedImportStrike)]
[InlineData(StrikeType.SlowSpeed, NotificationEventType.SlowSpeedStrike)]
[InlineData(StrikeType.SlowTime, NotificationEventType.SlowTimeStrike)]
public async Task NotifyStrike_MapsStrikeTypeToCorrectEventType(StrikeType strikeType, NotificationEventType expectedEventType)
{
// Arrange
SetupContext();
if (strikeType is StrikeType.Stalled or StrikeType.SlowSpeed or StrikeType.SlowTime)
{
var rule = new StallRule { Name = "Test Rule" };
ContextProvider.Set<QueueRule>(rule);
}
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(expectedEventType))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyStrike(strikeType, 1);
// Assert
_configServiceMock.Verify(c => c.GetProvidersForEventAsync(expectedEventType), Times.Once);
}
[Fact]
public async Task NotifyStrike_WhenNoProviders_DoesNotThrow()
{
// Arrange
SetupContext();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto>());
// Act & Assert - Should not throw
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
}
[Fact]
public async Task NotifyStrike_WhenProviderThrows_LogsWarningAndContinues()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.ThrowsAsync(new Exception("Provider failed"));
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act - Should not throw
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region NotifyQueueItemDeleted Tests
[Fact]
public async Task NotifyQueueItemDeleted_SendsNotificationWithCorrectContext()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.QueueItemDeleted &&
c.Data["Reason"] == "Stalled" &&
c.Data["Removed from client?"] == "True" &&
c.Severity == EventSeverity.Important)), Times.Once);
}
[Fact]
public async Task NotifyQueueItemDeleted_WhenRemoveFromClientFalse_ReflectsInContext()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.MalwareFileFound);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.Data["Removed from client?"] == "False" &&
c.Data["Reason"] == "MalwareFileFound")), Times.Once);
}
#endregion
#region NotifyDownloadCleaned Tests
[Fact]
public async Task NotifyDownloadCleaned_SendsNotificationWithCorrectContext()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyDownloadCleaned(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.DownloadCleaned &&
c.Description == "Test Download" &&
c.Data["Category"] == "movies" &&
c.Data["Ratio"] == "2.5" &&
c.Data["Seeding hours"] == "48")), Times.Once);
}
[Fact]
public async Task NotifyDownloadCleaned_WithSeedingTime_RoundsToWholeHours()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
NotificationContext? capturedContext = null;
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.Callback<NotificationContext>(c => capturedContext = c)
.Returns(Task.CompletedTask);
// Act
await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(24.7), "tv", CleanReason.MaxSeedTimeReached);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal("25", capturedContext.Data["Seeding hours"]); // Rounds to 25
}
#endregion
#region NotifyCategoryChanged Tests
[Fact]
public async Task NotifyCategoryChanged_WhenNotTag_IncludesOldAndNewCategory()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyCategoryChanged("tv-sonarr", "seeding", false);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.CategoryChanged &&
c.Title == "Category changed" &&
c.Data["Old category"] == "tv-sonarr" &&
c.Data["New category"] == "seeding")), Times.Once);
}
[Fact]
public async Task NotifyCategoryChanged_WhenIsTag_IncludesOnlyTag()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
NotificationContext? capturedContext = null;
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.Callback<NotificationContext>(c => capturedContext = c)
.Returns(Task.CompletedTask);
// Act
await _publisher.NotifyCategoryChanged("", "seeded", true);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal("Tag added", capturedContext.Title);
Assert.True(capturedContext.Data.ContainsKey("Tag"));
Assert.Equal("seeded", capturedContext.Data["Tag"]);
Assert.False(capturedContext.Data.ContainsKey("Old category"));
Assert.False(capturedContext.Data.ContainsKey("New category"));
}
[Fact]
public async Task NotifyCategoryChanged_SetsSeverityToInformation()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyCategoryChanged("old", "new", false);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.Severity == EventSeverity.Information)), Times.Once);
}
#endregion
#region SendNotificationAsync Tests (through notify methods)
[Fact]
public async Task SendNotificationAsync_WhenMultipleProviders_SendsToAll()
{
// Arrange
SetupContext();
var providerDto1 = CreateProviderDto("Provider1");
var providerDto2 = CreateProviderDto("Provider2");
var providerMock1 = new Mock<INotificationProvider>();
var providerMock2 = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto1, providerDto2 });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto1))
.Returns(providerMock1.Object);
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto2))
.Returns(providerMock2.Object);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
providerMock1.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
}
[Fact]
public async Task SendNotificationAsync_WhenOneProviderFails_OthersStillSend()
{
// Arrange
SetupContext();
var providerDto1 = CreateProviderDto("Provider1");
var providerDto2 = CreateProviderDto("Provider2");
var providerMock1 = new Mock<INotificationProvider>();
var providerMock2 = new Mock<INotificationProvider>();
providerMock1.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.ThrowsAsync(new Exception("Failed"));
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto1, providerDto2 });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto1))
.Returns(providerMock1.Object);
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto2))
.Returns(providerMock2.Object);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert - Provider2 should still be called
providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
}
[Fact]
public async Task SendNotificationAsync_UsesDryRunInterceptor()
{
// Arrange
SetupContext();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto>());
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
It.IsAny<Func<(NotificationEventType, NotificationContext), Task>>(),
It.IsAny<(NotificationEventType, NotificationContext)>()), Times.Once);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task NotifyStrike_WhenExceptionOccurs_LogsError()
{
// Arrange
// Setup dry run interceptor to throw when called
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.ThrowsAsync(new Exception("Interceptor failed"));
SetupContext();
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to notify strike")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task NotifyQueueItemDeleted_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.ThrowsAsync(new Exception("Error"));
SetupContext();
// Act
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify queue item deleted")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task NotifyDownloadCleaned_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.ThrowsAsync(new Exception("Error"));
SetupDownloadCleanerContext();
// Act
await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(1), "test", CleanReason.MaxRatioReached);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify download cleaned")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task NotifyCategoryChanged_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.ThrowsAsync(new Exception("Error"));
SetupDownloadCleanerContext();
// Act
await _publisher.NotifyCategoryChanged("old", "new", false);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify category changed")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region Helper Methods
private static NotificationProviderDto CreateProviderDto(string name = "TestProvider")
{
return new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true
},
Configuration = new { ApiKey = "test", ChannelId = "123" }
};
}
#endregion
}

View File

@@ -0,0 +1,354 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationServiceTests
{
private readonly Mock<ILogger<NotificationService>> _loggerMock;
private readonly Mock<INotificationConfigurationService> _configServiceMock;
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
private readonly NotificationService _service;
public NotificationServiceTests()
{
_loggerMock = new Mock<ILogger<NotificationService>>();
_configServiceMock = new Mock<INotificationConfigurationService>();
_providerFactoryMock = new Mock<INotificationProviderFactory>();
_service = new NotificationService(
_loggerMock.Object,
_configServiceMock.Object,
_providerFactoryMock.Object);
}
#region SendNotificationAsync Tests
[Fact]
public async Task SendNotificationAsync_NoProviders_DoesNotSendNotifications()
{
// Arrange
var eventType = NotificationEventType.QueueItemDeleted;
var context = CreateTestContext();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
.ReturnsAsync(new List<NotificationProviderDto>());
// Act
await _service.SendNotificationAsync(eventType, context);
// Assert
_providerFactoryMock.Verify(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()), Times.Never);
}
[Fact]
public async Task SendNotificationAsync_WithProvider_SendsNotification()
{
// Arrange
var eventType = NotificationEventType.DownloadCleaned;
var context = CreateTestContext();
var providerConfig = CreateProviderConfig("TestProvider");
var providerMock = new Mock<INotificationProvider>();
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
.ReturnsAsync(new List<NotificationProviderDto> { providerConfig });
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
.Returns(providerMock.Object);
// Act
await _service.SendNotificationAsync(eventType, context);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
}
[Fact]
public async Task SendNotificationAsync_WithMultipleProviders_SendsToAll()
{
// Arrange
var eventType = NotificationEventType.StalledStrike;
var context = CreateTestContext();
var provider1Config = CreateProviderConfig("Provider1");
var provider2Config = CreateProviderConfig("Provider2");
var provider1Mock = new Mock<INotificationProvider>();
provider1Mock.SetupGet(p => p.Name).Returns("Provider1");
var provider2Mock = new Mock<INotificationProvider>();
provider2Mock.SetupGet(p => p.Name).Returns("Provider2");
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
.ReturnsAsync(new List<NotificationProviderDto> { provider1Config, provider2Config });
_providerFactoryMock.Setup(f => f.CreateProvider(provider1Config))
.Returns(provider1Mock.Object);
_providerFactoryMock.Setup(f => f.CreateProvider(provider2Config))
.Returns(provider2Mock.Object);
// Act
await _service.SendNotificationAsync(eventType, context);
// Assert
provider1Mock.Verify(p => p.SendNotificationAsync(context), Times.Once);
provider2Mock.Verify(p => p.SendNotificationAsync(context), Times.Once);
}
[Fact]
public async Task SendNotificationAsync_OneProviderFails_OthersStillExecute()
{
// Arrange
var eventType = NotificationEventType.CategoryChanged;
var context = CreateTestContext();
var failingProviderConfig = CreateProviderConfig("FailingProvider");
var successProviderConfig = CreateProviderConfig("SuccessProvider");
var failingProviderMock = new Mock<INotificationProvider>();
failingProviderMock.SetupGet(p => p.Name).Returns("FailingProvider");
failingProviderMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.ThrowsAsync(new Exception("Provider failed"));
var successProviderMock = new Mock<INotificationProvider>();
successProviderMock.SetupGet(p => p.Name).Returns("SuccessProvider");
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
.ReturnsAsync(new List<NotificationProviderDto> { failingProviderConfig, successProviderConfig });
_providerFactoryMock.Setup(f => f.CreateProvider(failingProviderConfig))
.Returns(failingProviderMock.Object);
_providerFactoryMock.Setup(f => f.CreateProvider(successProviderConfig))
.Returns(successProviderMock.Object);
// Act
await _service.SendNotificationAsync(eventType, context);
// Assert - both providers should have been called
failingProviderMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
successProviderMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
}
[Fact]
public async Task SendNotificationAsync_ProviderFails_LogsWarning()
{
// Arrange
var eventType = NotificationEventType.QueueItemDeleted;
var context = CreateTestContext();
var providerConfig = CreateProviderConfig("FailingProvider");
var providerMock = new Mock<INotificationProvider>();
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.ThrowsAsync(new Exception("Provider failed"));
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
.ReturnsAsync(new List<NotificationProviderDto> { providerConfig });
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
.Returns(providerMock.Object);
// Act
await _service.SendNotificationAsync(eventType, context);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task SendNotificationAsync_ConfigServiceThrows_LogsError()
{
// Arrange
var eventType = NotificationEventType.SlowSpeedStrike;
var context = CreateTestContext();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
.ThrowsAsync(new Exception("Config service failed"));
// Act
await _service.SendNotificationAsync(eventType, context);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notifications")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region SendTestNotificationAsync Tests
[Fact]
public async Task SendTestNotificationAsync_SendsTestContext()
{
// Arrange
var providerConfig = CreateProviderConfig("TestProvider");
var providerMock = new Mock<INotificationProvider>();
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
.Returns(providerMock.Object);
// Act
await _service.SendTestNotificationAsync(providerConfig);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(c =>
c.EventType == NotificationEventType.Test &&
c.Title == "Test Notification from Cleanuparr" &&
c.Description.Contains("test notification") &&
c.Severity == EventSeverity.Information &&
c.Data != null &&
c.Data.ContainsKey("Test time") &&
c.Data.ContainsKey("Provider type")
)), Times.Once);
}
[Fact]
public async Task SendTestNotificationAsync_Success_LogsInformation()
{
// Arrange
var providerConfig = CreateProviderConfig("TestProvider");
var providerMock = new Mock<INotificationProvider>();
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
.Returns(providerMock.Object);
// Act
await _service.SendTestNotificationAsync(providerConfig);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Test notification sent successfully")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task SendTestNotificationAsync_ProviderFails_ThrowsException()
{
// Arrange
var providerConfig = CreateProviderConfig("FailingProvider");
var providerMock = new Mock<INotificationProvider>();
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.ThrowsAsync(new Exception("Test notification failed"));
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
.Returns(providerMock.Object);
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _service.SendTestNotificationAsync(providerConfig));
}
[Fact]
public async Task SendTestNotificationAsync_ProviderFails_LogsError()
{
// Arrange
var providerConfig = CreateProviderConfig("FailingProvider");
var providerMock = new Mock<INotificationProvider>();
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.ThrowsAsync(new Exception("Test notification failed"));
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
.Returns(providerMock.Object);
// Act
try
{
await _service.SendTestNotificationAsync(providerConfig);
}
catch
{
// Expected
}
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send test notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task SendTestNotificationAsync_IncludesProviderTypeInData()
{
// Arrange
var providerConfig = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestNtfyProvider",
Type = NotificationProviderType.Ntfy,
IsEnabled = true
};
var providerMock = new Mock<INotificationProvider>();
providerMock.SetupGet(p => p.Name).Returns("TestNtfyProvider");
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
.Returns(providerMock.Object);
// Act
await _service.SendTestNotificationAsync(providerConfig);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(c =>
c.Data["Provider type"] == "Ntfy"
)), Times.Once);
}
#endregion
#region Helper Methods
private static NotificationContext CreateTestContext()
{
return new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Title",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>
{
["Key1"] = "Value1",
["Key2"] = "Value2"
}
};
}
private static NotificationProviderDto CreateProviderConfig(string name)
{
return new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Apprise,
IsEnabled = true
};
}
#endregion
}

View File

@@ -0,0 +1,344 @@
using System.Net;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Ntfy;
public class NtfyProxyTests
{
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public NtfyProxyTests()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private NtfyProxy CreateProxy()
{
return new NtfyProxy(_httpClientFactoryMock.Object);
}
private static NtfyPayload CreatePayload()
{
return new NtfyPayload
{
Topic = "test-topic",
Message = "Test message",
Title = "Test Title"
};
}
private static NtfyConfig CreateConfig(NtfyAuthenticationType authType = NtfyAuthenticationType.None)
{
return new NtfyConfig
{
ServerUrl = "http://ntfy.local",
Topics = new List<string> { "test-topic" },
AuthenticationType = authType,
Username = authType == NtfyAuthenticationType.BasicAuth ? "user" : null,
Password = authType == NtfyAuthenticationType.BasicAuth ? "pass" : null,
AccessToken = authType == NtfyAuthenticationType.AccessToken ? "token123" : null
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidFactory_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
#endregion
#region Authentication Tests
[Fact]
public async Task SendNotification_WithNoAuth_DoesNotSetAuthorizationHeader()
{
// Arrange
var proxy = CreateProxy();
bool hasAuthHeader = false;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
hasAuthHeader = req.Headers.Authorization != null)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.None));
// Assert
Assert.False(hasAuthHeader);
}
[Fact]
public async Task SendNotification_WithBasicAuth_SetsBasicAuthorizationHeader()
{
// Arrange
var proxy = CreateProxy();
string? capturedAuthScheme = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedAuthScheme = req.Headers.Authorization?.Scheme)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.BasicAuth));
// Assert
Assert.Equal("Basic", capturedAuthScheme);
}
[Fact]
public async Task SendNotification_WithAccessToken_SetsBearerAuthorizationHeader()
{
// Arrange
var proxy = CreateProxy();
string? capturedAuthScheme = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedAuthScheme = req.Headers.Authorization?.Scheme)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.AccessToken));
// Assert
Assert.Equal("Bearer", capturedAuthScheme);
}
#endregion
#region SendNotification Error Tests
[Fact]
public async Task SendNotification_When400_ThrowsNtfyExceptionWithBadRequest()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.BadRequest);
// Act & Assert
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Bad request", ex.Message);
}
[Fact]
public async Task SendNotification_When401_ThrowsNtfyExceptionWithUnauthorized()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.Unauthorized);
// Act & Assert
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Unauthorized", ex.Message);
}
[Fact]
public async Task SendNotification_When413_ThrowsNtfyExceptionWithPayloadTooLarge()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.RequestEntityTooLarge);
// Act & Assert
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Payload too large", ex.Message);
}
[Fact]
public async Task SendNotification_When429_ThrowsNtfyExceptionWithRateLimited()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.TooManyRequests);
// Act & Assert
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Rate limited", ex.Message);
}
[Fact]
public async Task SendNotification_When507_ThrowsNtfyExceptionWithInsufficientStorage()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InsufficientStorage);
// Act & Assert
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Insufficient storage", ex.Message);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsNtfyException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Unable to send notification", ex.Message);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsNtfyException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("Unable to send notification", ex.Message);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -0,0 +1,301 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NtfyProviderTests
{
private readonly Mock<INtfyProxy> _proxyMock;
private readonly NtfyConfig _config;
private readonly NtfyProvider _provider;
public NtfyProviderTests()
{
_proxyMock = new Mock<INtfyProxy>();
_config = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { "test-topic" },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.Default,
Tags = new List<string> { "tag1", "tag2" }
};
_provider = new NtfyProvider(
"TestNtfy",
NotificationProviderType.Ntfy,
_config,
_proxyMock.Object);
}
#region Constructor Tests
[Fact]
public void Constructor_SetsNameCorrectly()
{
// Assert
Assert.Equal("TestNtfy", _provider.Name);
}
[Fact]
public void Constructor_SetsTypeCorrectly()
{
// Assert
Assert.Equal(NotificationProviderType.Ntfy, _provider.Type);
}
#endregion
#region SendNotificationAsync Tests
[Fact]
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
{
// Arrange
var context = CreateTestContext();
NtfyPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("test-topic", capturedPayload.Topic);
Assert.Equal(context.Title, capturedPayload.Title);
Assert.Contains(context.Description, capturedPayload.Message);
}
[Fact]
public async Task SendNotificationAsync_WithMultipleTopics_SendsToAllTopics()
{
// Arrange
var config = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { "topic1", "topic2", "topic3" },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.Default,
Tags = new List<string>()
};
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
var context = CreateTestContext();
var capturedPayloads = new List<NtfyPayload>();
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayloads.Add(payload))
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
Assert.Equal(3, capturedPayloads.Count);
Assert.Contains(capturedPayloads, p => p.Topic == "topic1");
Assert.Contains(capturedPayloads, p => p.Topic == "topic2");
Assert.Contains(capturedPayloads, p => p.Topic == "topic3");
}
[Fact]
public async Task SendNotificationAsync_IncludesDataInMessage()
{
// Arrange
var context = CreateTestContext();
context.Data["TestKey"] = "TestValue";
context.Data["AnotherKey"] = "AnotherValue";
NtfyPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("TestKey: TestValue", capturedPayload.Message);
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Message);
}
[Fact]
public async Task SendNotificationAsync_UsesPriorityFromConfig()
{
// Arrange
var config = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { "test" },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.High,
Tags = new List<string>()
};
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
var context = CreateTestContext();
NtfyPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal((int)NtfyPriority.High, capturedPayload.Priority);
}
[Fact]
public async Task SendNotificationAsync_IncludesTagsFromConfig()
{
// Arrange
var context = CreateTestContext();
NtfyPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.NotNull(capturedPayload.Tags);
Assert.Contains("tag1", capturedPayload.Tags);
Assert.Contains("tag2", capturedPayload.Tags);
}
[Fact]
public async Task SendNotificationAsync_TrimsTopicNames()
{
// Arrange
var config = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { " topic-with-spaces " },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.Default,
Tags = new List<string>()
};
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
var context = CreateTestContext();
NtfyPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("topic-with-spaces", capturedPayload.Topic);
}
[Fact]
public async Task SendNotificationAsync_SkipsEmptyTopics()
{
// Arrange
var config = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { "valid-topic", "", " ", "another-valid" },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.Default,
Tags = new List<string>()
};
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
var context = CreateTestContext();
var capturedPayloads = new List<NtfyPayload>();
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayloads.Add(payload))
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
Assert.Equal(2, capturedPayloads.Count);
Assert.Contains(capturedPayloads, p => p.Topic == "valid-topic");
Assert.Contains(capturedPayloads, p => p.Topic == "another-valid");
}
[Fact]
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
{
// Arrange
var context = CreateTestContext();
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
.ThrowsAsync(new Exception("Proxy error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
}
[Fact]
public async Task SendNotificationAsync_WithEmptyData_MessageContainsOnlyDescription()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = "Test Title",
Description = "Test Description Only",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
NtfyPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("Test Description Only", capturedPayload.Message);
}
#endregion
#region Helper Methods
private static NotificationContext CreateTestContext()
{
return new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Notification",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
}
#endregion
}

View File

@@ -399,13 +399,12 @@ public class QueueRuleMatchTests
Assert.False(rule.MatchesTorrent(publicTorrent.Object));
}
private static Mock<ITorrentItem> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
private static Mock<ITorrentItemWrapper> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
{
var torrent = new Mock<ITorrentItem>();
var torrent = new Mock<ITorrentItemWrapper>();
torrent.SetupGet(t => t.IsPrivate).Returns(isPrivate);
torrent.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
torrent.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
torrent.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
return torrent;
}
}

View File

@@ -0,0 +1,98 @@
using Cleanuparr.Infrastructure.Health;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Xunit;
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
namespace Cleanuparr.Infrastructure.Tests.Health;
public class ApplicationHealthCheckTests
{
#region Constructor Tests
[Fact]
public void Constructor_CreatesInstance()
{
// Act
var healthCheck = new ApplicationHealthCheck();
// Assert
Assert.NotNull(healthCheck);
}
#endregion
#region CheckHealthAsync Tests
[Fact]
public async Task CheckHealthAsync_ReturnsHealthy()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_DescriptionIndicatesRunning()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Contains("running", result.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CheckHealthAsync_WithCancellationToken_CompletesSuccessfully()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
using var cts = new CancellationTokenSource();
// Act
var result = await healthCheck.CheckHealthAsync(null!, cts.Token);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_WithContext_CompletesSuccessfully()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
var context = new HealthCheckContext();
// Act
var result = await healthCheck.CheckHealthAsync(context);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_MultipleCalls_AllReturnHealthy()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
// Act
var result1 = await healthCheck.CheckHealthAsync(null!);
var result2 = await healthCheck.CheckHealthAsync(null!);
var result3 = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result1.Status);
Assert.Equal(HealthCheckStatus.Healthy, result2.Status);
Assert.Equal(HealthCheckStatus.Healthy, result3.Status);
}
#endregion
}

View File

@@ -0,0 +1,122 @@
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
namespace Cleanuparr.Infrastructure.Tests.Health;
/// <summary>
/// Basic tests for DatabaseHealthCheck.
/// Note: Full integration testing requires a real database since in-memory provider
/// doesn't support migrations (GetPendingMigrationsAsync).
/// </summary>
public class DatabaseHealthCheckTests : IDisposable
{
private readonly Mock<ILogger<DatabaseHealthCheck>> _loggerMock;
private DataContext? _dataContext;
public DatabaseHealthCheckTests()
{
_loggerMock = new Mock<ILogger<DatabaseHealthCheck>>();
}
public void Dispose()
{
_dataContext?.Dispose();
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Arrange
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_dataContext = new DataContext(options);
// Act
var healthCheck = new DatabaseHealthCheck(_dataContext, _loggerMock.Object);
// Assert
Assert.NotNull(healthCheck);
}
#endregion
#region Exception Handling Tests
[Fact]
public async Task CheckHealthAsync_WhenDisposedContext_ReturnsUnhealthy()
{
// Arrange
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var disposedContext = new DataContext(options);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_WhenUnhealthy_LogsError()
{
// Arrange
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var disposedContext = new DataContext(options);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
// Act
await healthCheck.CheckHealthAsync(null!);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task CheckHealthAsync_WhenUnhealthy_DescriptionIndicatesFailure()
{
// Arrange
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var disposedContext = new DataContext(options);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Contains("failed", result.Description, StringComparison.OrdinalIgnoreCase);
}
#endregion
}

View File

@@ -0,0 +1,242 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Health;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
using HealthStatus = Cleanuparr.Infrastructure.Health.HealthStatus;
namespace Cleanuparr.Infrastructure.Tests.Health;
public class DownloadClientsHealthCheckTests
{
private readonly Mock<IHealthCheckService> _healthCheckServiceMock;
private readonly Mock<ILogger<DownloadClientsHealthCheck>> _loggerMock;
private readonly DownloadClientsHealthCheck _healthCheck;
public DownloadClientsHealthCheckTests()
{
_healthCheckServiceMock = new Mock<IHealthCheckService>();
_loggerMock = new Mock<ILogger<DownloadClientsHealthCheck>>();
_healthCheck = new DownloadClientsHealthCheck(_healthCheckServiceMock.Object, _loggerMock.Object);
}
#region CheckHealthAsync Tests
[Fact]
public async Task CheckHealthAsync_WhenNoClientsConfigured_ReturnsHealthy()
{
// Arrange
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(new Dictionary<Guid, HealthStatus>());
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
Assert.Contains("No download clients configured", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WhenAllClientsHealthy_ReturnsHealthy()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateHealthyStatus("Client2") },
{ Guid.NewGuid(), CreateHealthyStatus("Client3") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
Assert.Contains("All 3 download clients are healthy", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WhenSomeClientsUnhealthy_ReturnsDegraded()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateHealthyStatus("Client2") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client3") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Degraded, result.Status);
Assert.Contains("1/3", result.Description);
Assert.Contains("Client3", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WhenMajorityUnhealthy_ReturnsUnhealthy()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client3") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
Assert.Contains("2/3", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WhenAllUnhealthy_ReturnsUnhealthy()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateUnhealthyStatus("Client1") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_WhenServiceThrows_ReturnsUnhealthy()
{
// Arrange
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Throws(new Exception("Service error"));
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
Assert.Contains("Download clients health check failed", result.Description);
}
[Fact]
public async Task CheckHealthAsync_IncludesUnhealthyClientNames()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("HealthyClient") },
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient1") },
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient2") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Contains("BrokenClient1", result.Description);
Assert.Contains("BrokenClient2", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WithSingleClient_HandlesCorrectly()
{
// Arrange - Single healthy client
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("OnlyClient") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_WithSingleUnhealthyClient_ReturnsUnhealthy()
{
// Arrange - Single unhealthy client (1/1 > 50%)
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
}
#endregion
#region Helper Methods
private static HealthStatus CreateHealthyStatus(string clientName)
{
return new HealthStatus
{
IsHealthy = true,
ClientName = clientName,
ClientId = Guid.NewGuid(),
LastChecked = DateTime.UtcNow,
ClientTypeName = DownloadClientTypeName.qBittorrent
};
}
private static HealthStatus CreateUnhealthyStatus(string clientName)
{
return new HealthStatus
{
IsHealthy = false,
ClientName = clientName,
ClientId = Guid.NewGuid(),
LastChecked = DateTime.UtcNow,
ErrorMessage = "Connection failed",
ClientTypeName = DownloadClientTypeName.qBittorrent
};
}
#endregion
}

View File

@@ -0,0 +1,345 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Health;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Health;
public class HealthCheckBackgroundServiceTests : IDisposable
{
private readonly Mock<ILogger<HealthCheckBackgroundService>> _loggerMock;
private readonly Mock<IHealthCheckService> _healthCheckServiceMock;
private HealthCheckBackgroundService? _service;
public HealthCheckBackgroundServiceTests()
{
_loggerMock = new Mock<ILogger<HealthCheckBackgroundService>>();
_healthCheckServiceMock = new Mock<IHealthCheckService>();
}
public void Dispose()
{
_service?.Dispose();
}
private HealthCheckBackgroundService CreateService()
{
_service = new HealthCheckBackgroundService(
_loggerMock.Object,
_healthCheckServiceMock.Object);
return _service;
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var service = CreateService();
// Assert
Assert.NotNull(service);
}
#endregion
#region ExecuteAsync Tests
[Fact]
public async Task ExecuteAsync_WhenCancelledImmediately_StopsGracefully()
{
// Arrange
var service = CreateService();
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act
await service.StartAsync(cts.Token);
await service.StopAsync(CancellationToken.None);
// Assert - Should not throw
}
[Fact]
public async Task ExecuteAsync_CallsCheckAllClientsHealthAsync()
{
// Arrange
var service = CreateService();
var healthResults = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") }
};
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
// Give it some time to execute at least once
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert
_healthCheckServiceMock.Verify(s => s.CheckAllClientsHealthAsync(), Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WhenAllClientsHealthy_LogsDebugMessage()
{
// Arrange
var service = CreateService();
var healthResults = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateHealthyStatus("Client2") }
};
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Check that debug log was called (all healthy)
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("healthy")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WhenSomeClientsUnhealthy_LogsWarningMessage()
{
// Arrange
var service = CreateService();
var healthResults = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2", "Connection failed") }
};
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Check that warning log was called for unhealthy clients
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unhealthy")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WhenHealthCheckThrows_LogsErrorAndContinues()
{
// Arrange
var service = CreateService();
var callCount = 0;
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
throw new Exception("Health check failed");
}
return new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") }
};
});
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Error should be logged
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error performing periodic health check")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WithNoClients_HandlesEmptyResults()
{
// Arrange
var service = CreateService();
var healthResults = new Dictionary<Guid, HealthStatus>();
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Should handle gracefully
_healthCheckServiceMock.Verify(s => s.CheckAllClientsHealthAsync(), Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_LogsDetailedInfoForUnhealthyClients()
{
// Arrange
var service = CreateService();
var unhealthyClientId = Guid.NewGuid();
var healthResults = new Dictionary<Guid, HealthStatus>
{
{ unhealthyClientId, CreateUnhealthyStatus("UnhealthyClient", "Connection timeout") }
};
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Should log details about the unhealthy client
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("UnhealthyClient") ||
v.ToString()!.Contains("Connection timeout")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
#endregion
#region Lifecycle Tests
[Fact]
public async Task StartAsync_StartsBackgroundService()
{
// Arrange
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(new Dictionary<Guid, HealthStatus>());
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
// Assert
Assert.NotNull(service);
// Cleanup
cts.Cancel();
await service.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopAsync_StopsGracefully()
{
// Arrange
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(new Dictionary<Guid, HealthStatus>());
var service = CreateService();
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
// Act
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Should log stop message
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopped")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
#endregion
#region Helper Methods
private static HealthStatus CreateHealthyStatus(string clientName)
{
return new HealthStatus
{
IsHealthy = true,
ClientName = clientName,
ClientId = Guid.NewGuid(),
LastChecked = DateTime.UtcNow,
ResponseTime = TimeSpan.FromMilliseconds(50),
ClientTypeName = DownloadClientTypeName.qBittorrent
};
}
private static HealthStatus CreateUnhealthyStatus(string clientName, string errorMessage)
{
return new HealthStatus
{
IsHealthy = false,
ClientName = clientName,
ClientId = Guid.NewGuid(),
LastChecked = DateTime.UtcNow,
ResponseTime = TimeSpan.Zero,
ErrorMessage = errorMessage,
ClientTypeName = DownloadClientTypeName.qBittorrent
};
}
#endregion
}

View File

@@ -1,91 +0,0 @@
// using Common.Configuration;
// using Common.Enums;
// using Infrastructure.Configuration;
// using Infrastructure.Health;
// using Infrastructure.Verticals.DownloadClient;
// using Infrastructure.Verticals.DownloadClient.Factory;
// using Microsoft.Extensions.Logging;
// using NSubstitute;
// using NSubstitute.ExceptionExtensions;
//
// namespace Infrastructure.Tests.Health;
//
// public class HealthCheckServiceFixture : IDisposable
// {
// public ILogger<HealthCheckService> Logger { get; }
// public IConfigManager ConfigManager { get; }
// public IDownloadClientFactory ClientFactory { get; }
// public IDownloadService MockClient { get; }
// public DownloadClientConfigs DownloadClientConfigs { get; }
//
// public HealthCheckServiceFixture()
// {
// Logger = Substitute.For<ILogger<HealthCheckService>>();
// ConfigManager = Substitute.For<IConfigManager>();
// ClientFactory = Substitute.For<IDownloadClientFactory>();
// MockClient = Substitute.For<IDownloadService>();
// Guid clientId = Guid.NewGuid();
//
// // Set up test download client config
// DownloadClientConfigs = new DownloadClientConfigs
// {
// Clients = new List<DownloadClientConfig>
// {
// new()
// {
// Id = clientId,
// Name = "Test QBittorrent",
// Type = DownloadClientType.QBittorrent,
// Enabled = true,
// Username = "admin",
// Password = "adminadmin"
// },
// new()
// {
// Id = Guid.NewGuid(),
// Name = "Test Transmission",
// Type = DownloadClientType.Transmission,
// Enabled = true,
// Username = "admin",
// Password = "adminadmin"
// },
// new()
// {
// Id = Guid.NewGuid(),
// Name = "Disabled Client",
// Type = DownloadClientType.QBittorrent,
// Enabled = false,
// }
// }
// };
//
// // Set up the mock client factory
// ClientFactory.GetClient(Arg.Any<Guid>()).Returns(MockClient);
// MockClient.GetClientId().Returns(clientId);
//
// // Set up mock config manager
// ConfigManager.GetConfiguration<DownloadClientConfigs>().Returns(DownloadClientConfigs);
// }
//
// public HealthCheckService CreateSut()
// {
// return new HealthCheckService(Logger, ConfigManager, ClientFactory);
// }
//
// public void SetupHealthyClient(Guid clientId)
// {
// // Setup a client that will successfully login
// MockClient.LoginAsync().Returns(Task.CompletedTask);
// }
//
// public void SetupUnhealthyClient(Guid clientId, string errorMessage = "Failed to connect")
// {
// // Setup a client that will fail to login
// MockClient.LoginAsync().Throws(new Exception(errorMessage));
// }
//
// public void Dispose()
// {
// // Cleanup if needed
// }
// }

View File

@@ -1,177 +0,0 @@
// using Infrastructure.Health;
// using NSubstitute;
// using Shouldly;
//
// namespace Infrastructure.Tests.Health;
//
// public class HealthCheckServiceTests : IClassFixture<HealthCheckServiceFixture>
// {
// private readonly HealthCheckServiceFixture _fixture;
//
// public HealthCheckServiceTests(HealthCheckServiceFixture fixture)
// {
// _fixture = fixture;
// }
//
// [Fact]
// public async Task CheckClientHealthAsync_WithHealthyClient_ShouldReturnHealthyStatus()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Act
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.IsHealthy.ShouldBeTrue(),
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001")),
// () => result.ErrorMessage.ShouldBeNull(),
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
// );
// }
//
// [Fact]
// public async Task CheckClientHealthAsync_WithUnhealthyClient_ShouldReturnUnhealthyStatus()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000001"), "Connection refused");
//
// // Act
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.IsHealthy.ShouldBeFalse(),
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001")),
// () => result.ErrorMessage?.ShouldContain("Connection refused"),
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
// );
// }
//
// [Fact]
// public async Task CheckClientHealthAsync_WithNonExistentClient_ShouldReturnErrorStatus()
// {
// // Arrange
// var sut = _fixture.CreateSut();
//
// // Configure the ConfigManager to return null for the client config
// _fixture.ConfigManager.GetConfigurationAsync<DownloadClientConfigs>().Returns(
// Task.FromResult<DownloadClientConfigs>(new())
// );
//
// // Act
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000010"));
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.IsHealthy.ShouldBeFalse(),
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000010")),
// () => result.ErrorMessage?.ShouldContain("not found"),
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
// );
// }
//
// [Fact]
// public async Task CheckAllClientsHealthAsync_ShouldReturnAllEnabledClients()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000002"));
//
// // Act
// var results = await sut.CheckAllClientsHealthAsync();
//
// // Assert
// results.Count.ShouldBe(2); // Only enabled clients
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000001"));
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000002"));
// results[new Guid("00000000-0000-0000-0000-000000000001")].IsHealthy.ShouldBeTrue();
// results[new Guid("00000000-0000-0000-0000-000000000002")].IsHealthy.ShouldBeFalse();
// }
//
// [Fact]
// public async Task ClientHealthChanged_ShouldRaiseEventOnHealthStateChange()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
//
// ClientHealthChangedEventArgs? capturedArgs = null;
// sut.ClientHealthChanged += (_, args) => capturedArgs = args;
//
// // Act - first check establishes initial state
// var firstResult = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Setup client to be unhealthy for second check
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Act - second check changes state
// var secondResult = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// capturedArgs.ShouldNotBeNull();
// capturedArgs.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001"));
// capturedArgs.Status.IsHealthy.ShouldBeFalse();
// capturedArgs.IsDegraded.ShouldBeTrue();
// capturedArgs.IsRecovered.ShouldBeFalse();
// }
//
// [Fact]
// public async Task GetClientHealth_ShouldReturnCachedStatus()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Perform a check to cache the status
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Act
// var result = sut.GetClientHealth(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// result.ShouldNotBeNull();
// result.IsHealthy.ShouldBeTrue();
// result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001"));
// }
//
// [Fact]
// public void GetClientHealth_WithNoCheck_ShouldReturnNull()
// {
// // Arrange
// var sut = _fixture.CreateSut();
//
// // Act
// var result = sut.GetClientHealth(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// result.ShouldBeNull();
// }
//
// [Fact]
// public async Task GetAllClientHealth_ShouldReturnAllCheckedClients()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000002"));
//
// // Perform checks to cache statuses
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000002"));
//
// // Act
// var results = sut.GetAllClientHealth();
//
// // Assert
// results.Count.ShouldBe(2);
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000001"));
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000002"));
// results[new Guid("00000000-0000-0000-0000-000000000001")].IsHealthy.ShouldBeTrue();
// results[new Guid("00000000-0000-0000-0000-000000000002")].IsHealthy.ShouldBeFalse();
// }
// }

View File

@@ -0,0 +1,148 @@
using Cleanuparr.Infrastructure.Models;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Models;
public class ValidationResultTests
{
[Fact]
public void Success_ReturnsValidResult()
{
// Act
var result = ValidationResult.Success();
// Assert
result.IsValid.ShouldBeTrue();
}
[Fact]
public void Success_HasEmptyErrorMessage()
{
// Act
var result = ValidationResult.Success();
// Assert
result.ErrorMessage.ShouldBe(string.Empty);
}
[Fact]
public void Success_HasEmptyDetails()
{
// Act
var result = ValidationResult.Success();
// Assert
result.Details.ShouldBeEmpty();
}
[Fact]
public void Failure_ReturnsInvalidResult()
{
// Act
var result = ValidationResult.Failure("Error occurred");
// Assert
result.IsValid.ShouldBeFalse();
}
[Fact]
public void Failure_ContainsErrorMessage()
{
// Arrange
const string errorMessage = "Validation failed";
// Act
var result = ValidationResult.Failure(errorMessage);
// Assert
result.ErrorMessage.ShouldBe(errorMessage);
}
[Fact]
public void Failure_WithDetails_ContainsAllDetails()
{
// Arrange
const string errorMessage = "Multiple errors";
var details = new List<string> { "Error 1", "Error 2", "Error 3" };
// Act
var result = ValidationResult.Failure(errorMessage, details);
// Assert
result.Details.Count.ShouldBe(3);
result.Details.ShouldContain("Error 1");
result.Details.ShouldContain("Error 2");
result.Details.ShouldContain("Error 3");
}
[Fact]
public void Failure_WithoutDetails_HasEmptyDetailsList()
{
// Act
var result = ValidationResult.Failure("Error");
// Assert
result.Details.ShouldNotBeNull();
result.Details.ShouldBeEmpty();
}
[Fact]
public void Failure_WithNullDetails_HasEmptyDetailsList()
{
// Act
var result = ValidationResult.Failure("Error", null);
// Assert
result.Details.ShouldNotBeNull();
result.Details.ShouldBeEmpty();
}
[Fact]
public void DefaultConstructor_IsValidIsFalse()
{
// Act
var result = new ValidationResult();
// Assert
result.IsValid.ShouldBeFalse();
}
[Fact]
public void DefaultConstructor_ErrorMessageIsEmpty()
{
// Act
var result = new ValidationResult();
// Assert
result.ErrorMessage.ShouldBe(string.Empty);
}
[Fact]
public void DefaultConstructor_DetailsIsEmptyList()
{
// Act
var result = new ValidationResult();
// Assert
result.Details.ShouldNotBeNull();
result.Details.ShouldBeEmpty();
}
[Fact]
public void Properties_CanBeSetDirectly()
{
// Arrange
var result = new ValidationResult();
// Act
result.IsValid = true;
result.ErrorMessage = "Test error";
result.Details = new List<string> { "Detail 1" };
// Assert
result.IsValid.ShouldBeTrue();
result.ErrorMessage.ShouldBe("Test error");
result.Details.ShouldContain("Detail 1");
}
}

View File

@@ -0,0 +1,178 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Cleanuparr.Domain.Entities.AppStatus;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class AppStatusRefreshServiceTests : IDisposable
{
private readonly Mock<ILogger<AppStatusRefreshService>> _loggerMock;
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly AppStatusSnapshot _snapshot;
private readonly JsonSerializerOptions _jsonOptions;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private AppStatusRefreshService? _service;
public AppStatusRefreshServiceTests()
{
_loggerMock = new Mock<ILogger<AppStatusRefreshService>>();
_hubContextMock = new Mock<IHubContext<AppHub>>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_snapshot = new AppStatusSnapshot();
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
_httpHandlerMock = new Mock<HttpMessageHandler>();
// Setup hub context
var clientsMock = new Mock<IHubClients>();
var clientProxyMock = new Mock<IClientProxy>();
clientsMock.Setup(c => c.All).Returns(clientProxyMock.Object);
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
}
public void Dispose()
{
_service?.Dispose();
}
private AppStatusRefreshService CreateService()
{
_service = new AppStatusRefreshService(
_loggerMock.Object,
_hubContextMock.Object,
_httpClientFactoryMock.Object,
_snapshot,
_jsonOptions);
return _service;
}
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
{
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(content, Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(_httpHandlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
}
#region Constructor Tests
[Fact]
public void Constructor_SetsAllDependencies()
{
// Act
var service = CreateService();
// Assert
Assert.NotNull(service);
}
#endregion
#region AppStatusSnapshot Integration Tests
[Fact]
public void AppStatusSnapshot_UpdateLatestVersion_ChangesStatusReturnsTrue()
{
// Arrange
var snapshot = new AppStatusSnapshot();
// Act
var result = snapshot.UpdateLatestVersion("1.0.0", out var status);
// Assert
Assert.True(result);
Assert.Equal("1.0.0", status.LatestVersion);
}
[Fact]
public void AppStatusSnapshot_UpdateLatestVersion_SameVersionReturnsFalse()
{
// Arrange
var snapshot = new AppStatusSnapshot();
snapshot.UpdateLatestVersion("1.0.0", out _);
// Act
var result = snapshot.UpdateLatestVersion("1.0.0", out var status);
// Assert
Assert.False(result);
Assert.Equal("1.0.0", status.LatestVersion);
}
[Fact]
public void AppStatusSnapshot_UpdateCurrentVersion_ChangesStatusReturnsTrue()
{
// Arrange
var snapshot = new AppStatusSnapshot();
// Act
var result = snapshot.UpdateCurrentVersion("2.0.0", out var status);
// Assert
Assert.True(result);
Assert.Equal("2.0.0", status.CurrentVersion);
}
[Fact]
public void AppStatusSnapshot_Current_ReturnsCurrentState()
{
// Arrange
var snapshot = new AppStatusSnapshot();
snapshot.UpdateCurrentVersion("1.0.0", out _);
snapshot.UpdateLatestVersion("2.0.0", out _);
// Act
var current = snapshot.Current;
// Assert
Assert.Equal("1.0.0", current.CurrentVersion);
Assert.Equal("2.0.0", current.LatestVersion);
}
[Fact]
public void AppStatusSnapshot_UpdateWithNull_HandlesCorrectly()
{
// Arrange
var snapshot = new AppStatusSnapshot();
snapshot.UpdateLatestVersion("1.0.0", out _);
// Act
var result = snapshot.UpdateLatestVersion(null, out var status);
// Assert
Assert.True(result);
Assert.Null(status.LatestVersion);
}
[Fact]
public void AppStatusSnapshot_UpdateWithSameNull_ReturnsFalse()
{
// Arrange
var snapshot = new AppStatusSnapshot();
// Act - Both are null initially
var result = snapshot.UpdateLatestVersion(null, out _);
// Assert
Assert.False(result);
}
#endregion
}

View File

@@ -0,0 +1,577 @@
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Moq;
using Quartz;
using Quartz.Impl.Matchers;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class JobManagementServiceTests
{
private readonly Mock<ILogger<JobManagementService>> _loggerMock;
private readonly Mock<ISchedulerFactory> _schedulerFactoryMock;
private readonly Mock<IScheduler> _schedulerMock;
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
private readonly JobManagementService _service;
public JobManagementServiceTests()
{
_loggerMock = new Mock<ILogger<JobManagementService>>();
_schedulerFactoryMock = new Mock<ISchedulerFactory>();
_schedulerMock = new Mock<IScheduler>();
_hubContextMock = new Mock<IHubContext<AppHub>>();
_schedulerFactoryMock.Setup(f => f.GetScheduler(It.IsAny<CancellationToken>()))
.ReturnsAsync(_schedulerMock.Object);
_service = new JobManagementService(_loggerMock.Object, _schedulerFactoryMock.Object, _hubContextMock.Object);
}
#region StartJob Tests
[Fact]
public async Task StartJob_WithInvalidDirectCronExpression_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var invalidCron = "invalid-cron";
// Act
var result = await _service.StartJob(jobType, directCronExpression: invalidCron);
// Assert
Assert.False(result);
}
[Fact]
public async Task StartJob_JobDoesNotExist_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("does not exist")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task StartJob_WithValidCronExpression_ReturnsTrue()
{
// Arrange
var jobType = JobType.QueueCleaner;
var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
_schedulerMock.Verify(s => s.ResumeJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StartJob_WithSchedule_ReturnsTrue()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.StartJob(jobType, schedule: schedule);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StartJob_WithNoScheduleOrCron_CreatesOneTimeTrigger()
{
// Arrange
var jobType = JobType.DownloadCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.StartJob(jobType);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(
It.Is<ITrigger>(t => t.Key.Name.Contains("onetime")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StartJob_CleansUpExistingTriggers_BeforeSchedulingNew()
{
// Arrange
var jobType = JobType.QueueCleaner;
var cronExpression = "0 0/5 * * * ?";
var existingTriggerMock = new Mock<ITrigger>();
existingTriggerMock.Setup(t => t.Key).Returns(new TriggerKey("existing-trigger"));
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger> { existingTriggerMock.Object });
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.UnscheduleJob(
It.Is<TriggerKey>(k => k.Name == "existing-trigger"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StartJob_WhenSchedulerThrows_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var cronExpression = "0 0/5 * * * ?";
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
// Assert
Assert.False(result);
}
#endregion
#region StopJob Tests
[Fact]
public async Task StopJob_JobDoesNotExist_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.StopJob(jobType);
// Assert
Assert.False(result);
}
[Fact]
public async Task StopJob_JobExists_CleansUpTriggersAndReturnsTrue()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var triggerMock = new Mock<ITrigger>();
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
// Act
var result = await _service.StopJob(jobType);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.UnscheduleJob(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StopJob_WhenSchedulerThrows_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.StopJob(jobType);
// Assert
Assert.False(result);
}
#endregion
#region GetJob Tests
[Fact]
public async Task GetJob_JobDoesNotExist_ReturnsNotFoundStatus()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.GetJob(jobType);
// Assert
Assert.Equal("Not Found", result.Status);
Assert.Equal("QueueCleaner", result.Name);
}
[Fact]
public async Task GetJob_JobExistsNoTriggers_ReturnsNotScheduledStatus()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
// Act
var result = await _service.GetJob(jobType);
// Assert
Assert.Equal("Not Scheduled", result.Status);
}
[Theory]
[InlineData(TriggerState.Normal, "Scheduled")]
[InlineData(TriggerState.Paused, "Paused")]
[InlineData(TriggerState.Complete, "Complete")]
[InlineData(TriggerState.Error, "Error")]
[InlineData(TriggerState.Blocked, "Running")]
[InlineData(TriggerState.None, "Not Scheduled")]
public async Task GetJob_WithTrigger_ReturnsCorrectStatus(TriggerState triggerState, string expectedStatus)
{
// Arrange
var jobType = JobType.QueueCleaner;
var triggerMock = new Mock<ITrigger>();
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
triggerMock.Setup(t => t.GetNextFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(5));
triggerMock.Setup(t => t.GetPreviousFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(-5));
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
_schedulerMock.Setup(s => s.GetTriggerState(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(triggerState);
// Act
var result = await _service.GetJob(jobType);
// Assert
Assert.Equal(expectedStatus, result.Status);
}
[Fact]
public async Task GetJob_WhenSchedulerThrows_ReturnsErrorStatus()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetJob(jobType);
// Assert
Assert.Equal("Error", result.Status);
}
#endregion
#region GetAllJobs Tests
[Fact]
public async Task GetAllJobs_NoJobs_ReturnsEmptyList()
{
// Arrange
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string>());
// Act
var result = await _service.GetAllJobs();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetAllJobs_WithJobs_ReturnsJobList()
{
// Arrange
var jobKey = new JobKey("QueueCleaner");
var triggerMock = new Mock<ITrigger>();
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
triggerMock.Setup(t => t.GetNextFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(5));
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "DEFAULT" });
_schedulerMock.Setup(s => s.GetJobKeys(It.IsAny<GroupMatcher<JobKey>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HashSet<JobKey> { jobKey });
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
_schedulerMock.Setup(s => s.GetTriggerState(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TriggerState.Normal);
// Act
var result = await _service.GetAllJobs();
// Assert
Assert.Single(result);
Assert.Equal("QueueCleaner", result[0].Name);
Assert.Equal("Scheduled", result[0].Status);
}
[Fact]
public async Task GetAllJobs_WhenSchedulerThrows_ReturnsEmptyList()
{
// Arrange
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetAllJobs();
// Assert
Assert.Empty(result);
}
#endregion
#region TriggerJobOnce Tests
[Fact]
public async Task TriggerJobOnce_JobDoesNotExist_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.TriggerJobOnce(jobType);
// Assert
Assert.False(result);
}
[Fact]
public async Task TriggerJobOnce_JobExists_TriggersJobAndReturnsTrue()
{
// Arrange
var jobType = JobType.MalwareBlocker;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.TriggerJobOnce(jobType);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(
It.Is<ITrigger>(t => t.Key.Name.Contains("immediate") && t.Key.Name.Contains("manual")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task TriggerJobOnce_WhenSchedulerThrows_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.TriggerJobOnce(jobType);
// Assert
Assert.False(result);
}
#endregion
#region UpdateJobSchedule Tests
[Fact]
public async Task UpdateJobSchedule_NullSchedule_ThrowsArgumentNullException()
{
// Arrange
var jobType = JobType.QueueCleaner;
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => _service.UpdateJobSchedule(jobType, null!));
}
[Fact]
public async Task UpdateJobSchedule_JobDoesNotExist_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.UpdateJobSchedule(jobType, schedule);
// Assert
Assert.False(result);
}
[Fact]
public async Task UpdateJobSchedule_ValidSchedule_ReturnsTrue()
{
// Arrange
var jobType = JobType.DownloadCleaner;
var schedule = new JobSchedule { Every = 10, Type = ScheduleUnit.Minutes };
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.UpdateJobSchedule(jobType, schedule);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpdateJobSchedule_WhenSchedulerThrows_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.UpdateJobSchedule(jobType, schedule);
// Assert
Assert.False(result);
}
#endregion
#region GetMainTrigger Tests
[Fact]
public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetMainTrigger_TriggerExists_ReturnsTrigger()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger");
var triggerMock = new Mock<ITrigger>();
triggerMock.Setup(t => t.Key).Returns(expectedTriggerKey);
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTrigger(expectedTriggerKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(triggerMock.Object);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
Assert.NotNull(result);
Assert.Equal(expectedTriggerKey, result.Key);
}
[Fact]
public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
Assert.Null(result);
}
#endregion
}

View File

@@ -43,7 +43,7 @@ public class RuleEvaluatorTests
};
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -56,13 +56,12 @@ public class RuleEvaluatorTests
long downloadedBytes = 0;
var torrentMock = new Mock<ITorrentItem>();
var torrentMock = new Mock<ITorrentItemWrapper>();
torrentMock.SetupGet(t => t.Hash).Returns("hash");
torrentMock.SetupGet(t => t.Name).Returns("Example Torrent");
torrentMock.SetupGet(t => t.IsPrivate).Returns(false);
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse("100 MB").Bytes);
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
// Seed cache with initial observation (no reset expected)
@@ -91,7 +90,7 @@ public class RuleEvaluatorTests
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((StallRule?)null);
var torrentMock = CreateTorrentMock();
@@ -115,7 +114,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -144,7 +143,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -172,7 +171,7 @@ public class RuleEvaluatorTests
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(failingRule);
strikerMock
@@ -197,7 +196,7 @@ public class RuleEvaluatorTests
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((SlowRule?)null);
var torrentMock = CreateTorrentMock();
@@ -221,7 +220,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -249,7 +248,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -277,7 +276,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -304,7 +303,7 @@ public class RuleEvaluatorTests
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(failingRule);
strikerMock
@@ -336,7 +335,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -374,7 +373,7 @@ public class RuleEvaluatorTests
maxTimeHours: 2);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -408,7 +407,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
@@ -437,7 +436,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -469,7 +468,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
@@ -497,7 +496,7 @@ public class RuleEvaluatorTests
maxTimeHours: 2);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
@@ -525,7 +524,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -559,7 +558,7 @@ public class RuleEvaluatorTests
maxTimeHours: 1);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -588,7 +587,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -620,7 +619,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -644,7 +643,7 @@ public class RuleEvaluatorTests
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
}
private static Mock<ITorrentItem> CreateTorrentMock(
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
Func<long>? downloadedBytesFactory = null,
bool isPrivate = false,
string hash = "hash",
@@ -652,13 +651,12 @@ public class RuleEvaluatorTests
double completionPercentage = 50,
string size = "100 MB")
{
var torrentMock = new Mock<ITorrentItem>();
var torrentMock = new Mock<ITorrentItemWrapper>();
torrentMock.SetupGet(t => t.Hash).Returns(hash);
torrentMock.SetupGet(t => t.Name).Returns(name);
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytesFactory?.Invoke() ?? 0);
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
torrentMock.SetupGet(t => t.Eta).Returns(7200);
@@ -720,7 +718,7 @@ public class RuleEvaluatorTests
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((StallRule?)null);
var torrentMock = CreateTorrentMock();
@@ -745,7 +743,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -774,7 +772,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -803,7 +801,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -830,7 +828,7 @@ public class RuleEvaluatorTests
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((SlowRule?)null);
var torrentMock = CreateTorrentMock();
@@ -855,7 +853,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -884,7 +882,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -919,7 +917,7 @@ public class RuleEvaluatorTests
deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -949,7 +947,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock

View File

@@ -379,18 +379,17 @@ public class RuleManagerTests
Assert.Equal(slowRule.Id, result.Id);
}
private static Mock<ITorrentItem> CreateTorrentMock(
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
bool isPrivate = false,
double completionPercentage = 50,
string size = "100 MB")
{
var torrentMock = new Mock<ITorrentItem>();
var torrentMock = new Mock<ITorrentItemWrapper>();
torrentMock.SetupGet(t => t.Hash).Returns("test-hash");
torrentMock.SetupGet(t => t.Name).Returns("Test Torrent");
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(0);
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
torrentMock.SetupGet(t => t.Eta).Returns(3600);

View File

@@ -0,0 +1,339 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class StrikerTests : IDisposable
{
private readonly IMemoryCache _cache;
private readonly ILogger<Striker> _logger;
private readonly EventPublisher _eventPublisher;
private readonly Striker _striker;
public StrikerTests()
{
_cache = new MemoryCache(new MemoryCacheOptions());
_logger = Substitute.For<ILogger<Striker>>();
// Create EventPublisher with mocked dependencies
var eventsContext = CreateMockEventsContext();
var hubContext = Substitute.For<IHubContext<AppHub>>();
var hubClients = Substitute.For<IHubClients>();
var clientProxy = Substitute.For<IClientProxy>();
hubContext.Clients.Returns(hubClients);
hubClients.All.Returns(clientProxy);
var eventLogger = Substitute.For<ILogger<EventPublisher>>();
var notificationPublisher = Substitute.For<INotificationPublisher>();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
// Configure dry run interceptor to just complete the task (we don't need actual DB saves in tests)
dryRunInterceptor
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(Task.CompletedTask);
_eventPublisher = new EventPublisher(
eventsContext,
hubContext,
eventLogger,
notificationPublisher,
dryRunInterceptor);
_striker = new Striker(_logger, _cache, _eventPublisher);
// Clear static state before each test
Striker.RecurringHashes.Clear();
// Set up required context for recurring item events and FailedImport strikes
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
ContextProvider.Set("ArrInstanceUrl", new Uri("http://localhost:8989"));
ContextProvider.Set(new QueueRecord
{
Title = "Test Item",
DownloadId = "test-download-id",
Protocol = "torrent",
Id = 1,
StatusMessages = []
});
}
private static EventsContext CreateMockEventsContext()
{
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new EventsContext(options);
}
public void Dispose()
{
_cache.Dispose();
Striker.RecurringHashes.Clear();
}
[Fact]
public async Task StrikeAndCheckLimit_FirstStrike_ReturnsFalse()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 3;
// Act
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
result.ShouldBeFalse();
}
[Fact]
public async Task StrikeAndCheckLimit_ReachesMaxStrikes_ReturnsTrue()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 3;
// Act - Strike 3 times
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
result.ShouldBeTrue();
}
[Fact]
public async Task StrikeAndCheckLimit_ExceedsMaxStrikes_ReturnsTrue_AndAddsToRecurringHashes()
{
// Arrange
const string hash = "ABC123";
const string itemName = "Recurring Item";
const ushort maxStrikes = 2;
// Act - Strike 3 times (exceeds max of 2)
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
result.ShouldBeTrue();
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
}
[Fact]
public async Task StrikeAndCheckLimit_DifferentStrikeTypes_TrackedSeparately()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 2;
// Act - Strike with different types
var stalledResult1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var slowSpeedResult1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
var stalledResult2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var slowSpeedResult2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
// Assert - Both should reach max independently
stalledResult1.ShouldBeFalse();
slowSpeedResult1.ShouldBeFalse();
stalledResult2.ShouldBeTrue(); // 2nd stalled strike = maxStrikes
slowSpeedResult2.ShouldBeTrue(); // 2nd slow speed strike = maxStrikes
}
[Fact]
public async Task StrikeAndCheckLimit_SameHash_AccumulatesStrikes()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 5;
// Act - Strike 4 times
var result1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result3 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result4 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert - None should trigger removal yet (need 5)
result1.ShouldBeFalse();
result2.ShouldBeFalse();
result3.ShouldBeFalse();
result4.ShouldBeFalse();
// 5th strike should trigger removal
var result5 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
result5.ShouldBeTrue();
}
[Fact]
public async Task ResetStrikeAsync_ClearsStrikeCount()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 3;
// Strike twice
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Act - Reset strikes
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled);
// Assert - Next strike should be treated as first (returns false)
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
result.ShouldBeFalse();
}
[Fact]
public async Task ResetStrikeAsync_OnlyResetsSpecifiedType()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 2;
// Strike with both types
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
// Act - Reset only Stalled strikes
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled);
// Assert - Stalled should be reset (1st strike = false), SlowSpeed should continue (2nd strike = true)
var stalledResult = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var slowSpeedResult = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
stalledResult.ShouldBeFalse(); // Reset, so this is strike #1
slowSpeedResult.ShouldBeTrue(); // Not reset, so this is strike #2 = maxStrikes
}
[Fact]
public async Task StrikeAndCheckLimit_ZeroMaxStrikes_ReturnsFalse()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 0;
// Act
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert - Should return false immediately without striking
result.ShouldBeFalse();
}
[Theory]
[InlineData((ushort)2, 0, false)] // Strike 1, max 2 -> below limit (1 < 2)
[InlineData((ushort)2, 1, true)] // Strike 2, max 2 -> at limit (2 >= 2)
[InlineData((ushort)3, 1, false)] // Strike 2, max 3 -> below limit (2 < 3)
[InlineData((ushort)3, 2, true)] // Strike 3, max 3 -> at limit (3 >= 3)
[InlineData((ushort)1, 0, true)] // Strike 1, max 1 -> at limit (1 >= 1)
public async Task StrikeAndCheckLimit_BoundaryConditions(ushort maxStrikes, int preStrikes, bool expectedResult)
{
// Arrange
const string hash = "boundary-test";
const string itemName = "Boundary Test Item";
// Pre-strike
for (int i = 0; i < preStrikes; i++)
{
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
}
// Act
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
result.ShouldBe(expectedResult);
}
[Theory]
[InlineData(StrikeType.Stalled)]
[InlineData(StrikeType.DownloadingMetadata)]
[InlineData(StrikeType.FailedImport)]
[InlineData(StrikeType.SlowSpeed)]
[InlineData(StrikeType.SlowTime)]
public async Task StrikeAndCheckLimit_AllStrikeTypes_WorkCorrectly(StrikeType strikeType)
{
// Arrange
const string hash = "type-test";
const string itemName = "Type Test Item";
const ushort maxStrikes = 2;
// Act
var result1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, strikeType);
var result2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, strikeType);
// Assert
result1.ShouldBeFalse();
result2.ShouldBeTrue();
}
[Fact]
public async Task StrikeAndCheckLimit_DifferentHashes_TrackedSeparately()
{
// Arrange
const string hash1 = "hash1";
const string hash2 = "hash2";
const string itemName = "Test Item";
const ushort maxStrikes = 2;
// Act - Strike hash1 twice, hash2 once
await _striker.StrikeAndCheckLimit(hash1, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash2, itemName, maxStrikes, StrikeType.Stalled);
var hash1Result = await _striker.StrikeAndCheckLimit(hash1, itemName, maxStrikes, StrikeType.Stalled);
var hash2Result = await _striker.StrikeAndCheckLimit(hash2, itemName, maxStrikes, StrikeType.Stalled);
// Assert
hash1Result.ShouldBeTrue(); // hash1 reached max (2 strikes)
hash2Result.ShouldBeTrue(); // hash2 reached max (2 strikes)
}
[Fact]
public async Task ResetStrikeAsync_NonExistentStrike_DoesNotThrow()
{
// Arrange
const string hash = "never-struck";
const string itemName = "Never Struck Item";
// Act & Assert - Should not throw
await Should.NotThrowAsync(async () =>
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled));
}
[Fact]
public async Task StrikeAndCheckLimit_RecurringItem_OnlyAddedOnceToRecurringHashes()
{
// Arrange
const string hash = "recurring-hash";
const string itemName = "Recurring Item";
const ushort maxStrikes = 1;
// Act - Strike multiple times past the limit
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert - Hash should only appear once in RecurringHashes
Striker.RecurringHashes.Count.ShouldBe(1);
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
}
}

View File

@@ -0,0 +1,164 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Utilities;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Utilities;
public class CronExpressionConverterTests
{
[Fact]
public void ConvertToCronExpression_Seconds_ReturnsCorrectFormat()
{
// Arrange
var schedule = new JobSchedule { Every = 30, Type = ScheduleUnit.Seconds };
// Act
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
// Assert
result.ShouldBe("0/30 * * ? * * *");
}
[Theory]
[InlineData(1, "0 0/1 * ? * * *")]
[InlineData(5, "0 0/5 * ? * * *")]
[InlineData(10, "0 0/10 * ? * * *")]
[InlineData(15, "0 0/15 * ? * * *")]
[InlineData(30, "0 0/30 * ? * * *")]
public void ConvertToCronExpression_Minutes_ReturnsCorrectFormat(int minutes, string expected)
{
// Arrange
var schedule = new JobSchedule { Every = minutes, Type = ScheduleUnit.Minutes };
// Act
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(1, "0 0 0/1 ? * * *")]
[InlineData(2, "0 0 0/2 ? * * *")]
[InlineData(4, "0 0 0/4 ? * * *")]
[InlineData(6, "0 0 0/6 ? * * *")]
[InlineData(12, "0 0 0/12 ? * * *")]
public void ConvertToCronExpression_Hours_ReturnsCorrectFormat(int hours, string expected)
{
// Arrange
var schedule = new JobSchedule { Every = hours, Type = ScheduleUnit.Hours };
// Act
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData("0 */5 * * * ?")]
[InlineData("0 0 */2 * * ?")]
[InlineData("0/30 * * ? * * *")]
public void IsValidCronExpression_ValidQuartzCron_ReturnsTrue(string cronExpression)
{
// Act
var result = CronExpressionConverter.IsValidCronExpression(cronExpression);
// Assert
result.ShouldBeTrue();
}
[Theory]
[InlineData("invalid")]
[InlineData("* * *")]
[InlineData("not a cron")]
[InlineData("0 0 0 0 0 0 0")]
public void IsValidCronExpression_InvalidCron_ReturnsFalse(string cronExpression)
{
// Act
var result = CronExpressionConverter.IsValidCronExpression(cronExpression);
// Assert
result.ShouldBeFalse();
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(10)]
[InlineData(12)]
[InlineData(15)]
[InlineData(20)]
[InlineData(30)]
public void ConvertToCronExpression_AllValidMinuteValues_Succeeds(int minutes)
{
// Arrange
var schedule = new JobSchedule { Every = minutes, Type = ScheduleUnit.Minutes };
// Act & Assert
Should.NotThrow(() => CronExpressionConverter.ConvertToCronExpression(schedule));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(6)]
[InlineData(8)]
[InlineData(12)]
public void ConvertToCronExpression_AllValidHourValues_Succeeds(int hours)
{
// Arrange
var schedule = new JobSchedule { Every = hours, Type = ScheduleUnit.Hours };
// Act & Assert
Should.NotThrow(() => CronExpressionConverter.ConvertToCronExpression(schedule));
}
[Theory]
[InlineData(7, ScheduleUnit.Minutes)] // 7 doesn't divide 60 evenly
[InlineData(45, ScheduleUnit.Minutes)] // 45 is not in the valid list
[InlineData(5, ScheduleUnit.Hours)] // 5 doesn't divide 24 evenly
[InlineData(7, ScheduleUnit.Hours)] // 7 doesn't divide 24 evenly
[InlineData(15, ScheduleUnit.Seconds)] // Only 30 seconds is valid
public void ConvertToCronExpression_InvalidValue_ThrowsValidationException(int value, ScheduleUnit unit)
{
// Arrange
var schedule = new JobSchedule { Every = value, Type = unit };
// Act & Assert
Should.Throw<ValidationException>(() => CronExpressionConverter.ConvertToCronExpression(schedule));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
[InlineData("0 0 0 32 1 ?")] // Invalid day of month (32)
[InlineData("0 0 0 ? 13 *")] // Invalid month (13)
[InlineData("0 60 * ? * *")] // Invalid minute (60)
[InlineData("0 0 25 ? * *")] // Invalid hour (25)
[InlineData("0 0 0 ? * 8")] // Invalid day of week (8)
public void IsValidCronExpression_InvalidInput_ReturnsFalse(string? cronExpression)
{
// Act
var result = CronExpressionConverter.IsValidCronExpression(cronExpression!);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void ConvertToCronExpression_NullSchedule_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => CronExpressionConverter.ConvertToCronExpression(null!));
}
}

View File

@@ -0,0 +1,136 @@
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Utilities;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Utilities;
public class CronValidationHelperTests
{
[Theory]
[InlineData("0 */5 * * * ?")] // Every 5 minutes
[InlineData("0 0 */2 * * ?")] // Every 2 hours
[InlineData("0 0 0/4 * * ?")] // Every 4 hours
[InlineData("*/30 * * * * ?")] // Every 30 seconds
public void ValidateCronExpression_ValidExpression_DoesNotThrow(string cronExpression)
{
// Act & Assert
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression));
}
[Theory]
[InlineData("invalid")]
[InlineData("* * *")]
[InlineData("0 0 0 0 0 0 0")]
[InlineData("not a cron")]
public void ValidateCronExpression_InvalidSyntax_ThrowsValidationException(string cronExpression)
{
// Act & Assert
var exception = Should.Throw<ValidationException>(
() => CronValidationHelper.ValidateCronExpression(cronExpression));
exception.Message.ShouldContain("Invalid cron expression");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ValidateCronExpression_NullOrEmpty_ThrowsValidationException(string? cronExpression)
{
// Act & Assert
var exception = Should.Throw<ValidationException>(
() => CronValidationHelper.ValidateCronExpression(cronExpression!));
exception.Message.ShouldContain("cannot be null or empty");
}
[Theory]
[InlineData("*/1 * * * * ?")] // Every 1 second
[InlineData("*/5 * * * * ?")] // Every 5 seconds
[InlineData("*/10 * * * * ?")] // Every 10 seconds
[InlineData("*/15 * * * * ?")] // Every 15 seconds
public void ValidateCronExpression_TriggersTooFast_ThrowsValidationException(string cronExpression)
{
// Act & Assert - minimum is 30 seconds
var exception = Should.Throw<ValidationException>(
() => CronValidationHelper.ValidateCronExpression(cronExpression));
exception.Message.ShouldContain("minimum");
}
[Theory]
[InlineData("0 0 0 * * ?")] // Once per day (24 hours)
[InlineData("0 0 0 1 * ?")] // Once per month
public void ValidateCronExpression_TriggersTooSlow_ThrowsValidationException(string cronExpression)
{
// Act & Assert - maximum is 6 hours
var exception = Should.Throw<ValidationException>(
() => CronValidationHelper.ValidateCronExpression(cronExpression));
exception.Message.ShouldContain("maximum");
}
[Theory]
[InlineData("*/1 * * * * ?")] // Every 1 second - too fast for other jobs
[InlineData("*/5 * * * * ?")] // Every 5 seconds - too fast for other jobs
public void ValidateCronExpression_MalwareBlocker_HasDifferentLimits(string cronExpression)
{
// Act & Assert - MalwareBlocker allows faster triggers (no minimum)
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression, JobType.MalwareBlocker));
}
[Fact]
public void ValidateCronExpression_AtExactMinimumInterval_DoesNotThrow()
{
// Arrange - exactly 30 seconds
const string cronExpression = "*/30 * * * * ?";
// Act & Assert
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression));
}
[Fact]
public void ValidateCronExpression_AtExactMaximumInterval_DoesNotThrow()
{
// Arrange - exactly 6 hours
const string cronExpression = "0 0 */6 * * ?";
// Act & Assert
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression));
}
[Fact]
public void ValidateCronExpression_NullJobType_UsesDefaultLimits()
{
// Arrange - 5 seconds would fail default limits but pass MalwareBlocker
const string cronExpression = "*/5 * * * * ?";
// Act & Assert - should fail because null uses default limits (30 second minimum)
var exception = Should.Throw<ValidationException>(
() => CronValidationHelper.ValidateCronExpression(cronExpression, null));
exception.Message.ShouldContain("minimum");
}
[Theory]
[InlineData(JobType.QueueCleaner)]
[InlineData(JobType.DownloadCleaner)]
[InlineData(JobType.BlacklistSynchronizer)]
public void ValidateCronExpression_NonMalwareBlockerJobs_EnforceMinimumLimit(JobType jobType)
{
// Arrange - 5 seconds is below minimum
const string cronExpression = "*/5 * * * * ?";
// Act & Assert
var exception = Should.Throw<ValidationException>(
() => CronValidationHelper.ValidateCronExpression(cronExpression, jobType));
exception.Message.ShouldContain("minimum");
}
[Theory]
[InlineData("0 0 */1 * * ?")] // Every 1 hour
[InlineData("0 */30 * * * ?")] // Every 30 minutes
[InlineData("0 */1 * * * ?")] // Every 1 minute
public void ValidateCronExpression_WithinValidRange_DoesNotThrow(string cronExpression)
{
// Act & Assert
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression));
}
}

View File

@@ -0,0 +1,183 @@
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Utilities;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Utilities;
public class ScheduleOptionsTests
{
[Fact]
public void GetValidValues_Seconds_Returns30()
{
// Act
var result = ScheduleOptions.GetValidValues(ScheduleUnit.Seconds);
// Assert
result.ShouldBe(new[] { 30 });
}
[Fact]
public void GetValidValues_Minutes_ReturnsDivisorsOf60()
{
// Act
var result = ScheduleOptions.GetValidValues(ScheduleUnit.Minutes);
// Assert
result.ShouldBe(new[] { 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30 });
}
[Fact]
public void GetValidValues_Hours_ReturnsDivisorsOf24()
{
// Act
var result = ScheduleOptions.GetValidValues(ScheduleUnit.Hours);
// Assert
result.ShouldBe(new[] { 1, 2, 3, 4, 6, 8, 12 });
}
[Fact]
public void IsValidValue_Seconds_30_ReturnsTrue()
{
// Act
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Seconds, 30);
// Assert
result.ShouldBeTrue();
}
[Theory]
[InlineData(1)]
[InlineData(15)]
[InlineData(45)]
[InlineData(60)]
public void IsValidValue_Seconds_InvalidValues_ReturnsFalse(int value)
{
// Act
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Seconds, value);
// Assert
result.ShouldBeFalse();
}
[Theory]
[InlineData(7)]
[InlineData(8)]
[InlineData(9)]
[InlineData(11)]
[InlineData(45)]
[InlineData(60)]
public void IsValidValue_Minutes_InvalidValues_ReturnsFalse(int value)
{
// 7 doesn't divide 60 evenly, and other values are not in the valid list
// Act
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Minutes, value);
// Assert
result.ShouldBeFalse();
}
[Theory]
[InlineData(5)]
[InlineData(7)]
[InlineData(9)]
[InlineData(10)]
[InlineData(11)]
[InlineData(24)]
public void IsValidValue_Hours_InvalidValues_ReturnsFalse(int value)
{
// These don't divide 24 evenly or aren't in valid list
// Act
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Hours, value);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void GetValidValues_InvalidUnit_ThrowsArgumentOutOfRangeException()
{
// Arrange
var invalidUnit = (ScheduleUnit)999;
// Act & Assert
Should.Throw<ArgumentOutOfRangeException>(() => ScheduleOptions.GetValidValues(invalidUnit));
}
[Fact]
public void IsValidValue_InvalidUnit_ThrowsArgumentOutOfRangeException()
{
// Arrange
var invalidUnit = (ScheduleUnit)999;
// Act & Assert
Should.Throw<ArgumentOutOfRangeException>(() => ScheduleOptions.IsValidValue(invalidUnit, 1));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(10)]
[InlineData(12)]
[InlineData(15)]
[InlineData(20)]
[InlineData(30)]
public void IsValidValue_Minutes_AllValidValues_ReturnsTrue(int value)
{
// Act
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Minutes, value);
// Assert
result.ShouldBeTrue();
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(6)]
[InlineData(8)]
[InlineData(12)]
public void IsValidValue_Hours_AllValidValues_ReturnsTrue(int value)
{
// Act
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Hours, value);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void ValidSecondValues_ContainsOnly30()
{
// Assert
ScheduleOptions.ValidSecondValues.Length.ShouldBe(1);
ScheduleOptions.ValidSecondValues.ShouldContain(30);
}
[Fact]
public void ValidMinuteValues_AllDivide60Evenly()
{
// Assert - all valid minute values should divide 60 evenly
foreach (var value in ScheduleOptions.ValidMinuteValues)
{
(60 % value).ShouldBe(0, $"Value {value} does not divide 60 evenly");
}
}
[Fact]
public void ValidHourValues_AllDivide24Evenly()
{
// Assert - all valid hour values should divide 24 evenly
foreach (var value in ScheduleOptions.ValidHourValues)
{
(24 % value).ShouldBe(0, $"Value {value} does not divide 24 evenly");
}
}
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": true,
"maxParallelThreads": -1,
"methodDisplay": "classAndMethod",
"diagnosticMessages": false,
"parallelAlgorithm": "aggressive"
}

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
@@ -17,7 +18,7 @@ namespace Cleanuparr.Infrastructure.Events;
/// <summary>
/// Service for publishing events to database and SignalR hub
/// </summary>
public class EventPublisher
public class EventPublisher : IEventPublisher
{
private readonly EventsContext _context;
private readonly IHubContext<AppHub> _appHubContext;

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Events.Interfaces;
public interface IEventPublisher
{
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null);
Task PublishManualAsync(string message, EventSeverity severity, object? data = null);
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName);
Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason);
Task PublishDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false);
Task PublishRecurringItem(string hash, string itemName, int strikeCount);
Task PublishSearchNotTriggered(string hash, string itemName);
}

View File

@@ -31,13 +31,41 @@ public static class TransmissionExtensions
return false;
}
public static string GetCategory(this TorrentInfo download)
public static string GetCategory(this TorrentInfo torrent)
{
if (string.IsNullOrEmpty(download.DownloadDir))
if (string.IsNullOrEmpty(torrent.DownloadDir))
{
return string.Empty;
}
return Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir));
return Path.GetFileName(Path.TrimEndingDirectorySeparator(torrent.DownloadDir));
}
/// <summary>
/// Appends a category to the download directory of the torrent.
/// </summary>
public static void AppendCategory(this TorrentInfo torrent, string category)
{
if (string.IsNullOrEmpty(category))
{
return;
}
torrent.DownloadDir = torrent.GetNewLocationByAppend(category);
}
public static string GetNewLocationByAppend(this TorrentInfo torrent, string category)
{
if (string.IsNullOrEmpty(category))
{
throw new ArgumentException("Category cannot be null or empty", nameof(category));
}
if (string.IsNullOrEmpty(torrent.DownloadDir))
{
throw new ArgumentException("DownloadDir cannot be null or empty", nameof(torrent.DownloadDir));
}
return string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.DownloadDir, category).Split(['\\', '/']));
}
}

View File

@@ -3,7 +3,7 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
namespace Cleanuparr.Infrastructure.Features.Arr;
public sealed class ArrClientFactory
public sealed class ArrClientFactory : IArrClientFactory
{
private readonly ISonarrClient _sonarrClient;
private readonly IRadarrClient _radarrClient;
@@ -12,11 +12,11 @@ public sealed class ArrClientFactory
private readonly IWhisparrClient _whisparrClient;
public ArrClientFactory(
SonarrClient sonarrClient,
RadarrClient radarrClient,
LidarrClient lidarrClient,
ReadarrClient readarrClient,
WhisparrClient whisparrClient
ISonarrClient sonarrClient,
IRadarrClient radarrClient,
ILidarrClient lidarrClient,
IReadarrClient readarrClient,
IWhisparrClient whisparrClient
)
{
_sonarrClient = sonarrClient;

View File

@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Arr;
public sealed class ArrQueueIterator
public sealed class ArrQueueIterator : IArrQueueIterator
{
private readonly ILogger<ArrQueueIterator> _logger;

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrClientFactory
{
IArrClient GetClient(InstanceType type);
}

View File

@@ -0,0 +1,9 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrQueueIterator
{
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action);
}

View File

@@ -19,14 +19,14 @@ public sealed class BlacklistSynchronizer : IHandler
{
private readonly ILogger<BlacklistSynchronizer> _logger;
private readonly DataContext _dataContext;
private readonly DownloadServiceFactory _downloadServiceFactory;
private readonly IDownloadServiceFactory _downloadServiceFactory;
private readonly FileReader _fileReader;
private readonly IDryRunInterceptor _dryRunInterceptor;
public BlacklistSynchronizer(
ILogger<BlacklistSynchronizer> logger,
DataContext dataContext,
DownloadServiceFactory downloadServiceFactory,
IDownloadServiceFactory downloadServiceFactory,
FileReader fileReader,
IDryRunInterceptor dryRunInterceptor
)

View File

@@ -0,0 +1,52 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
public sealed class DelugeClientWrapper : IDelugeClientWrapper
{
private readonly DelugeClient _client;
public DelugeClientWrapper(DelugeClient client)
{
_client = client;
}
public Task<bool> LoginAsync()
=> _client.LoginAsync();
public Task<bool> IsConnected()
=> _client.IsConnected();
public Task<bool> Connect()
=> _client.Connect();
public Task<DownloadStatus?> GetTorrentStatus(string hash)
=> _client.GetTorrentStatus(hash);
public Task<DelugeContents?> GetTorrentFiles(string hash)
=> _client.GetTorrentFiles(hash);
public Task<DelugeTorrent?> GetTorrent(string hash)
=> _client.GetTorrent(hash);
public Task<DelugeTorrentExtended?> GetTorrentExtended(string hash)
=> _client.GetTorrentExtended(hash);
public Task<List<DownloadStatus>?> GetStatusForAllTorrents()
=> _client.GetStatusForAllTorrents();
public Task DeleteTorrents(List<string> hashes)
=> _client.DeleteTorrents(hashes);
public Task ChangeFilesPriority(string hash, List<int> priorities)
=> _client.ChangeFilesPriority(hash, priorities);
public Task<IReadOnlyList<string>> GetLabels()
=> _client.GetLabels();
public Task CreateLabel(string label)
=> _client.CreateLabel(label);
public Task SetTorrentLabel(string hash, string newLabel)
=> _client.SetTorrentLabel(hash, newLabel);
}

View File

@@ -1,115 +0,0 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
/// <summary>
/// Wrapper for Deluge DownloadStatus that implements ITorrentItem interface
/// </summary>
public sealed class DelugeItem : ITorrentItem
{
private readonly DownloadStatus _downloadStatus;
public DelugeItem(DownloadStatus downloadStatus)
{
_downloadStatus = downloadStatus ?? throw new ArgumentNullException(nameof(downloadStatus));
}
// Basic identification
public string Hash => _downloadStatus.Hash ?? string.Empty;
public string Name => _downloadStatus.Name ?? string.Empty;
// Privacy and tracking
public bool IsPrivate => _downloadStatus.Private;
public IReadOnlyList<string> Trackers => _downloadStatus.Trackers?
.Where(t => !string.IsNullOrEmpty(t.Url))
.Select(t => ExtractHostFromUrl(t.Url!))
.Where(host => !string.IsNullOrEmpty(host))
.Distinct()
.ToList()
.AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
// Size and progress
public long Size => _downloadStatus.Size;
public double CompletionPercentage => _downloadStatus.Size > 0
? (_downloadStatus.TotalDone / (double)_downloadStatus.Size) * 100.0
: 0.0;
public long DownloadedBytes => _downloadStatus.TotalDone;
public long TotalUploaded => (long)(_downloadStatus.Ratio * _downloadStatus.TotalDone);
// Speed and transfer rates
public long DownloadSpeed => _downloadStatus.DownloadSpeed;
public long UploadSpeed => 0; // Deluge DownloadStatus doesn't expose upload speed
public double Ratio => _downloadStatus.Ratio;
// Time tracking
public long Eta => (long)_downloadStatus.Eta;
public DateTime? DateAdded => null; // Deluge DownloadStatus doesn't expose date added
public DateTime? DateCompleted => null; // Deluge DownloadStatus doesn't expose date completed
public long SeedingTimeSeconds => _downloadStatus.SeedingTime;
// Categories and tags
public string? Category => _downloadStatus.Label;
public IReadOnlyList<string> Tags => Array.Empty<string>(); // Deluge doesn't have tags
// State checking methods
public bool IsDownloading() => _downloadStatus.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsStalled() => _downloadStatus.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true && _downloadStatus.DownloadSpeed == 0 && _downloadStatus.Eta == 0;
public bool IsSeeding() => _downloadStatus.State?.Equals("Seeding", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsCompleted() => CompletionPercentage >= 100.0;
public bool IsPaused() => _downloadStatus.State?.Equals("Paused", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsQueued() => _downloadStatus.State?.Equals("Queued", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsChecking() => _downloadStatus.State?.Equals("Checking", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsAllocating() => _downloadStatus.State?.Equals("Allocating", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsMetadataDownloading() => false; // Deluge doesn't have this state
// Filtering methods
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{
if (ignoredDownloads.Count == 0)
{
return false;
}
foreach (string pattern in ignoredDownloads)
{
if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (_downloadStatus.Trackers.Any(x => UriService.GetDomain(x.Url)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) is true))
{
return true;
}
}
return false;
}
/// <summary>
/// Extracts the host from a tracker URL
/// </summary>
private static string ExtractHostFromUrl(string url)
{
try
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return uri.Host;
}
}
catch
{
// Ignore parsing errors
}
return string.Empty;
}
}

View File

@@ -0,0 +1,78 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
/// <summary>
/// Wrapper for Deluge DownloadStatus that implements ITorrentItem interface
/// </summary>
public sealed class DelugeItemWrapper : ITorrentItemWrapper
{
public DownloadStatus Info { get; }
public DelugeItemWrapper(DownloadStatus downloadStatus)
{
Info = downloadStatus ?? throw new ArgumentNullException(nameof(downloadStatus));
}
public string Hash => Info.Hash ?? string.Empty;
public string Name => Info.Name ?? string.Empty;
public bool IsPrivate => Info.Private;
public long Size => Info.Size;
public double CompletionPercentage => Info.Size > 0
? (Info.TotalDone / (double)Info.Size) * 100.0
: 0.0;
public long DownloadedBytes => Info.TotalDone;
public long DownloadSpeed => Info.DownloadSpeed;
public double Ratio => Info.Ratio;
public long Eta => (long)Info.Eta;
public long SeedingTimeSeconds => Info.SeedingTime;
public string? Category
{
get => Info.Label;
set => Info.Label = value;
}
public bool IsDownloading() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsStalled() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true && Info is { DownloadSpeed: <= 0, Eta: <= 0 };
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{
if (ignoredDownloads.Count == 0)
{
return false;
}
foreach (string pattern in ignoredDownloads)
{
if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (Info.Trackers.Any(x => UriService.GetDomain(x.Url)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) is true))
{
return true;
}
}
return false;
}
}

View File

@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
@@ -15,7 +16,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
public partial class DelugeService : DownloadService, IDelugeService
{
private readonly DelugeClient _client;
private readonly IDelugeClientWrapper _client;
public DelugeService(
ILogger<DelugeService> logger,
@@ -25,7 +26,7 @@ public partial class DelugeService : DownloadService, IDelugeService
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
EventPublisher eventPublisher,
IEventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
@@ -36,7 +37,32 @@ public partial class DelugeService : DownloadService, IDelugeService
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
_client = new DelugeClient(downloadClientConfig, _httpClient);
var delugeClient = new DelugeClient(downloadClientConfig, _httpClient);
_client = new DelugeClientWrapper(delugeClient);
}
// Internal constructor for testing
internal DelugeService(
ILogger<DelugeService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
IEventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IDelugeClientWrapper clientWrapper
) : base(
logger, cache,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
_client = clientWrapper;
}
public override async Task LoginAsync()

View File

@@ -10,99 +10,36 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
public partial class DelugeService
{
public override async Task<List<ITorrentItem>?> GetSeedingDownloads()
public override async Task<List<ITorrentItemWrapper>> GetSeedingDownloads()
{
var downloads = await _client.GetStatusForAllTorrents();
if (downloads is null)
{
return null;
return [];
}
return downloads
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
.Select(x => (ITorrentItem)new DelugeItem(x))
.Select(ITorrentItemWrapper (x) => new DelugeItemWrapper(x))
.ToList();
}
public override List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories) =>
downloads
?.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
public override List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public override async Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent)
{
if (downloads?.Count is null or 0)
{
return;
}
foreach (ITorrentItem download in downloads)
{
if (string.IsNullOrEmpty(download.Hash))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
if (download.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
CleanCategory? category = categoriesToClean
.FirstOrDefault(x => x.Name.Equals(download.Category, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
continue;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!downloadCleanerConfig.DeletePrivate && download.IsPrivate)
{
_logger.LogDebug("skip | download is private | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTimeSeconds);
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime, category);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
download.Name
);
await _eventPublisher.PublishDownloadCleaned(download.Ratio, seedingTime, category.Name, result.Reason);
}
await DeleteDownload(torrent.Hash);
}
public override async Task CreateCategoryAsync(string name)
@@ -119,7 +56,7 @@ public partial class DelugeService
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
{
if (downloads?.Count is null or 0)
{
@@ -133,52 +70,33 @@ public partial class DelugeService
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
}
foreach (ITorrentItem download in downloads)
foreach (DelugeItemWrapper torrent in downloads.Cast<DelugeItemWrapper>())
{
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Category))
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
ContextProvider.Set("downloadName", torrent.Name);
ContextProvider.Set("hash", torrent.Hash);
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
if (download.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
// Get the underlying DownloadStatus to access DownloadLocation
DownloadStatus? downloadStatus = await _client.GetTorrentStatus(download.Hash);
if (downloadStatus is null)
{
_logger.LogDebug("failed to find torrent status for {name}", download.Name);
continue;
}
DelugeContents? contents = null;
DelugeContents? contents;
try
{
contents = await _client.GetTorrentFiles(download.Hash);
contents = await _client.GetTorrentFiles(torrent.Hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent files for {name}", download.Name);
_logger.LogDebug(exception, "failed to find torrent files for {name}", torrent.Name);
continue;
}
bool hasHardlinks = false;
bool hasErrors = false;
ProcessFiles(contents?.Contents, (_, file) =>
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(downloadStatus.DownloadLocation, file.Path).Split(['\\', '/']));
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadLocation, file.Path).Split(['\\', '/']));
if (file.Priority <= 0)
{
@@ -191,8 +109,8 @@ public partial class DelugeService
if (hardlinkCount < 0)
{
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
hasHardlinks = true;
_logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath);
hasErrors = true;
return;
}
@@ -202,17 +120,24 @@ public partial class DelugeService
}
});
if (hasHardlinks)
if (hasErrors)
{
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name);
continue;
}
_logger.LogInformation("category changed for {name}", download.Name);
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory);
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
}
}

View File

@@ -12,8 +12,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
public partial class DelugeService
{
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash,
IReadOnlyList<string> ignoredDownloads)
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
hash = hash.ToLowerInvariant();
@@ -24,7 +23,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -32,11 +31,11 @@ public partial class DelugeService
result.Found = true;
// Create ITorrentItem wrapper for consistent interface usage
var torrentItem = new DelugeItem(download);
DelugeItemWrapper torrent = new(download);
if (torrentItem.IsIgnored(ignoredDownloads))
if (torrent.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", torrentItem.Name);
_logger.LogInformation("skip | download is ignored | {name}", torrent.Name);
return result;
}
@@ -46,7 +45,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find files in the download client | {name}", torrentItem.Name);
_logger.LogDebug(exception, "failed to find files in the download client | {name}", torrent.Name);
}
@@ -63,7 +62,7 @@ public partial class DelugeService
if (shouldRemove)
{
// remove if all files are unwanted
_logger.LogTrace("all files are unwanted | removing download | {name}", torrentItem.Name);
_logger.LogTrace("all files are unwanted | removing download | {name}", torrent.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
result.DeleteFromClient = true;
@@ -71,48 +70,48 @@ public partial class DelugeService
}
// remove if download is stuck
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrentItem);
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent);
return result;
}
private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItem torrentItem)
private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(torrentItem);
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(wrapper);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(torrentItem);
return await CheckIfStuck(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItem torrentItem)
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!torrentItem.IsDownloading())
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", torrentItem.Name);
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
if (torrentItem.DownloadSpeed <= 0)
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", torrentItem.Name);
_logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(torrentItem);
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItem torrentItem)
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (!torrentItem.IsStalled())
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", torrentItem.Name);
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(torrentItem);
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -0,0 +1,20 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
public interface IDelugeClientWrapper
{
Task<bool> LoginAsync();
Task<bool> IsConnected();
Task<bool> Connect();
Task<DownloadStatus?> GetTorrentStatus(string hash);
Task<DelugeContents?> GetTorrentFiles(string hash);
Task<DelugeTorrent?> GetTorrent(string hash);
Task<DelugeTorrentExtended?> GetTorrentExtended(string hash);
Task<List<DownloadStatus>?> GetStatusForAllTorrents();
Task DeleteTorrents(List<string> hashes);
Task ChangeFilesPriority(string hash, List<int> priorities);
Task<IReadOnlyList<string>> GetLabels();
Task CreateLabel(string label);
Task SetTorrentLabel(string hash, string newLabel);
}

View File

@@ -1,18 +1,16 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Cache;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -35,7 +33,7 @@ public abstract class DownloadService : IDownloadService
protected readonly MemoryCacheEntryOptions _cacheOptions;
protected readonly IDryRunInterceptor _dryRunInterceptor;
protected readonly IHardLinkFileService _hardLinkFileService;
protected readonly EventPublisher _eventPublisher;
protected readonly IEventPublisher _eventPublisher;
protected readonly BlocklistProvider _blocklistProvider;
protected readonly HttpClient _httpClient;
protected readonly DownloadClientConfig _downloadClientConfig;
@@ -50,7 +48,7 @@ public abstract class DownloadService : IDownloadService
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
EventPublisher eventPublisher,
IEventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
@@ -81,32 +79,91 @@ public abstract class DownloadService : IDownloadService
public abstract Task<HealthCheckResult> HealthCheckAsync();
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash,
IReadOnlyList<string> ignoredDownloads);
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc/>
public abstract Task DeleteDownload(string hash);
/// <inheritdoc/>
public abstract Task<List<ITorrentItem>?> GetSeedingDownloads();
public abstract Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <inheritdoc/>
public abstract List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories);
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories);
/// <inheritdoc/>
public abstract List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories);
public abstract List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories);
/// <inheritdoc/>
public abstract Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
public virtual async Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categoriesToClean)
{
if (downloads?.Count is null or 0)
{
return;
}
foreach (ITorrentItemWrapper torrent in downloads)
{
if (string.IsNullOrEmpty(torrent.Hash))
{
continue;
}
CleanCategory? category = categoriesToClean
.FirstOrDefault(x => (torrent.Category ?? string.Empty).Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
continue;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!downloadCleanerConfig.DeletePrivate && torrent.IsPrivate)
{
_logger.LogDebug("skip | download is private | {name}", torrent.Name);
continue;
}
ContextProvider.Set("downloadName", torrent.Name);
ContextProvider.Set("hash", torrent.Hash);
TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds);
SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, category);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(DeleteDownloadInternal, torrent);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
torrent.Name
);
await _eventPublisher.PublishDownloadCleaned(torrent.Ratio, seedingTime, category.Name, result.Reason);
}
}
/// <inheritdoc/>
public abstract Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
public abstract Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads);
/// <inheritdoc/>
public abstract Task CreateCategoryAsync(string name);
/// <inheritdoc/>
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <summary>
/// Deletes the specified download from the download client.
/// Each client implementation handles the deletion according to its API requirements.
/// </summary>
/// <param name="torrent">The torrent to delete</param>
protected abstract Task DeleteDownloadInternal(ITorrentItemWrapper torrent);
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category)
{
@@ -175,7 +232,7 @@ public abstract class DownloadService : IDownloadService
return false;
}
// max ration is 0 or reached
// max ratio is 0 or reached
return true;
}

View File

@@ -1,5 +1,6 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
@@ -21,7 +22,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// Factory responsible for creating download client service instances
/// </summary>
public sealed class DownloadServiceFactory
public sealed class DownloadServiceFactory : IDownloadServiceFactory
{
private readonly ILogger<DownloadServiceFactory> _logger;
private readonly IServiceProvider _serviceProvider;
@@ -66,7 +67,7 @@ public sealed class DownloadServiceFactory
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
@@ -90,7 +91,7 @@ public sealed class DownloadServiceFactory
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
@@ -114,7 +115,7 @@ public sealed class DownloadServiceFactory
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
@@ -138,7 +139,7 @@ public sealed class DownloadServiceFactory
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();

View File

@@ -24,14 +24,13 @@ public interface IDownloadService : IDisposable
/// </summary>
/// <param name="hash">The download hash.</param>
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
public Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash,
IReadOnlyList<string> ignoredDownloads);
public Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <summary>
/// Fetches all seeding downloads.
/// </summary>
/// <returns>A list of downloads that are seeding.</returns>
Task<List<ITorrentItem>?> GetSeedingDownloads();
Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <summary>
/// Filters downloads that should be cleaned.
@@ -39,7 +38,7 @@ public interface IDownloadService : IDisposable
/// <param name="downloads">The downloads to filter.</param>
/// <param name="categories">The categories by which to filter the downloads.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories);
List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories);
/// <summary>
/// Filters downloads that should have their category changed.
@@ -47,24 +46,20 @@ public interface IDownloadService : IDisposable
/// <param name="downloads">The downloads to filter.</param>
/// <param name="categories">The categories by which to filter the downloads.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories);
List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories);
/// <summary>
/// Cleans the downloads.
/// </summary>
/// <param name="downloads">The downloads to clean.</param>
/// <param name="categoriesToClean">The categories that should be cleaned.</param>
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
/// <param name="ignoredDownloads">The downloads to ignore from processing.</param>
Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categoriesToClean);
/// <summary>
/// Changes the category for downloads that have no hardlinks.
/// </summary>
/// <param name="downloads">The downloads to change.</param>
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
/// <param name="ignoredDownloads">The downloads to ignore from processing.</param>
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads);
/// <summary>
/// Deletes a download item.

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
public interface IDownloadServiceFactory
{
IDownloadService GetDownloadService(DownloadClientConfig downloadClientConfig);
}

View File

@@ -0,0 +1,23 @@
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
/// <summary>
/// Wrapper interface for QBittorrentClient to enable testing
/// </summary>
public interface IQBittorrentClientWrapper : IDisposable
{
Task LoginAsync(string username, string password);
Task<ApiVersion> GetApiVersionAsync();
Task<IReadOnlyList<TorrentInfo>> GetTorrentListAsync(TorrentListQuery query);
Task<TorrentProperties?> GetTorrentPropertiesAsync(string hash);
Task<IReadOnlyList<TorrentContent>> GetTorrentContentsAsync(string hash);
Task<IReadOnlyList<TorrentTracker>> GetTorrentTrackersAsync(string hash);
Task<IReadOnlyDictionary<string, Category>> GetCategoriesAsync();
Task AddCategoryAsync(string category);
Task DeleteAsync(IEnumerable<string> hashes, bool deleteDownloadedData);
Task AddTorrentTagAsync(IEnumerable<string> hashes, string tag);
Task SetTorrentCategoryAsync(IEnumerable<string> hashes, string category);
Task SetFilePriorityAsync(string hash, int fileIndex, TorrentContentPriority priority);
Task SetPreferencesAsync(Preferences preferences);
}

View File

@@ -1,123 +0,0 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
/// <summary>
/// Wrapper for QBittorrent TorrentInfo that implements ITorrentItem interface
/// </summary>
public sealed class QBitItem : ITorrentItem
{
private readonly TorrentInfo _torrentInfo;
private readonly IReadOnlyList<TorrentTracker> _trackers;
private readonly bool _isPrivate;
public QBitItem(TorrentInfo torrentInfo, IReadOnlyList<TorrentTracker> trackers, bool isPrivate)
{
_torrentInfo = torrentInfo ?? throw new ArgumentNullException(nameof(torrentInfo));
_trackers = trackers ?? throw new ArgumentNullException(nameof(trackers));
_isPrivate = isPrivate;
}
// Basic identification
public string Hash => _torrentInfo.Hash ?? string.Empty;
public string Name => _torrentInfo.Name ?? string.Empty;
// Privacy and tracking
public bool IsPrivate => _isPrivate;
public IReadOnlyList<string> Trackers => _trackers
.Where(t => !string.IsNullOrEmpty(t.Url))
.Select(t => ExtractHostFromUrl(t.Url!))
.Where(host => !string.IsNullOrEmpty(host))
.Distinct()
.ToList()
.AsReadOnly();
// Size and progress
public long Size => _torrentInfo.Size;
public double CompletionPercentage => _torrentInfo.Progress * 100.0;
public long DownloadedBytes => _torrentInfo.Downloaded ?? 0;
public long TotalUploaded => _torrentInfo.Uploaded ?? 0;
// Speed and transfer rates
public long DownloadSpeed => _torrentInfo.DownloadSpeed;
public long UploadSpeed => _torrentInfo.UploadSpeed;
public double Ratio => _torrentInfo.Ratio;
// Time tracking
public long Eta => _torrentInfo.EstimatedTime?.TotalSeconds is double eta ? (long)eta : 0;
public DateTime? DateAdded => _torrentInfo.AddedOn;
public DateTime? DateCompleted => _torrentInfo.CompletionOn;
public long SeedingTimeSeconds => _torrentInfo.SeedingTime?.TotalSeconds is double seedTime ? (long)seedTime : 0;
// Categories and tags
public string? Category => _torrentInfo.Category;
public IReadOnlyList<string> Tags => _torrentInfo.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
// State checking methods
public bool IsDownloading() => _torrentInfo.State is TorrentState.Downloading or TorrentState.ForcedDownload;
public bool IsStalled() => _torrentInfo.State is TorrentState.StalledDownload;
public bool IsSeeding() => _torrentInfo.State is TorrentState.Uploading or TorrentState.ForcedUpload or TorrentState.StalledUpload;
public bool IsCompleted() => CompletionPercentage >= 100.0;
public bool IsPaused() => _torrentInfo.State is TorrentState.PausedDownload or TorrentState.PausedUpload;
public bool IsQueued() => _torrentInfo.State is TorrentState.QueuedDownload or TorrentState.QueuedUpload;
public bool IsChecking() => _torrentInfo.State is TorrentState.CheckingDownload or TorrentState.CheckingUpload or TorrentState.CheckingResumeData;
public bool IsAllocating() => _torrentInfo.State is TorrentState.Allocating;
public bool IsMetadataDownloading() => _torrentInfo.State is TorrentState.FetchingMetadata or TorrentState.ForcedFetchingMetadata;
// Filtering methods
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{
if (ignoredDownloads.Count == 0)
{
return false;
}
foreach (string pattern in ignoredDownloads)
{
if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (_torrentInfo.Tags?.Contains(pattern, StringComparer.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (_trackers.Any(tracker => tracker.ShouldIgnore(ignoredDownloads)))
{
return true;
}
}
return false;
}
/// <summary>
/// Extracts the host from a tracker URL
/// </summary>
private static string ExtractHostFromUrl(string url)
{
try
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return uri.Host;
}
}
catch
{
// Ignore parsing errors
}
return string.Empty;
}
}

View File

@@ -0,0 +1,92 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
/// <summary>
/// Wrapper for QBittorrent TorrentInfo that implements ITorrentItem interface
/// </summary>
public sealed class QBitItemWrapper : ITorrentItemWrapper
{
private readonly IReadOnlyList<TorrentTracker> _trackers;
public TorrentInfo Info { get; }
public QBitItemWrapper(TorrentInfo torrentInfo, IReadOnlyList<TorrentTracker> trackers, bool isPrivate)
{
Info = torrentInfo ?? throw new ArgumentNullException(nameof(torrentInfo));
_trackers = trackers ?? throw new ArgumentNullException(nameof(trackers));
IsPrivate = isPrivate;
}
public string Hash => Info.Hash ?? string.Empty;
public string Name => Info.Name ?? string.Empty;
public bool IsPrivate { get; }
public long Size => Info.Size;
public double CompletionPercentage => Info.Progress * 100.0;
public long DownloadedBytes => Info.Downloaded ?? 0;
public long DownloadSpeed => Info.DownloadSpeed;
public double Ratio => Info.Ratio;
public long Eta => Info.EstimatedTime?.TotalSeconds is { } eta ? (long)eta : 0;
public long SeedingTimeSeconds => Info.SeedingTime?.TotalSeconds is { } seedTime ? (long)seedTime : 0;
public string? Category
{
get => Info.Category;
set => Info.Category = value;
}
public IReadOnlyList<string> Tags => Info.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
public bool IsDownloading() => Info.State is TorrentState.Downloading or TorrentState.ForcedDownload;
public bool IsStalled() => Info.State is TorrentState.StalledDownload;
public bool IsSeeding() => Info.State is TorrentState.Uploading or TorrentState.ForcedUpload or TorrentState.StalledUpload;
public bool IsMetadataDownloading() => Info.State is TorrentState.FetchingMetadata or TorrentState.ForcedFetchingMetadata;
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{
if (ignoredDownloads.Count == 0)
{
return false;
}
foreach (string pattern in ignoredDownloads)
{
if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (Info.Tags?.Contains(pattern, StringComparer.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (_trackers.Any(tracker => tracker.ShouldIgnore(ignoredDownloads)))
{
return true;
}
}
return false;
}
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
@@ -16,7 +17,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
public partial class QBitService : DownloadService, IQBitService
{
protected readonly QBittorrentClient _client;
protected readonly IQBittorrentClientWrapper _client;
public QBitService(
ILogger<QBitService> logger,
@@ -26,7 +27,7 @@ public partial class QBitService : DownloadService, IQBitService
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
EventPublisher eventPublisher,
IEventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
@@ -36,7 +37,31 @@ public partial class QBitService : DownloadService, IQBitService
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
_client = new QBittorrentClient(_httpClient, downloadClientConfig.Url);
var qBittorrentClient = new QBittorrentClient(_httpClient, downloadClientConfig.Url);
_client = new QBittorrentClientWrapper(qBittorrentClient);
}
// Internal constructor for testing
internal QBitService(
ILogger<QBitService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
IEventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IQBittorrentClientWrapper clientWrapper
) : base(
logger, cache, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
_client = clientWrapper;
}
public override async Task LoginAsync()

View File

@@ -1,6 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.Extensions.Logging;
@@ -11,15 +10,15 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
public partial class QBitService
{
/// <inheritdoc/>
public override async Task<List<ITorrentItem>?> GetSeedingDownloads()
public override async Task<List<ITorrentItemWrapper>> GetSeedingDownloads()
{
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Completed });
if (torrentList is null)
{
return null;
return [];
}
var result = new List<ITorrentItem>();
var result = new List<ITorrentItemWrapper>();
foreach (var torrent in torrentList.Where(x => !string.IsNullOrEmpty(x.Hash)))
{
var trackers = await GetTrackersAsync(torrent.Hash!);
@@ -27,21 +26,21 @@ public partial class QBitService
bool isPrivate = properties?.AdditionalData.TryGetValue("is_private", out var dictValue) == true &&
bool.TryParse(dictValue?.ToString(), out bool boolValue) && boolValue;
result.Add(new QBitItem(torrent, trackers, isPrivate));
result.Add(new QBitItemWrapper(torrent, trackers, isPrivate));
}
return result;
}
/// <inheritdoc/>
public override List<ITorrentItem>? FilterDownloadsToBeCleanedAsync(List<ITorrentItem>? downloads, List<CleanCategory> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public override List<ITorrentItem>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItem>? downloads, List<string> categories)
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories)
{
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
@@ -50,9 +49,9 @@ public partial class QBitService
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x =>
{
if (downloadCleanerConfig.UnlinkedUseTag)
if (downloadCleanerConfig.UnlinkedUseTag && x is QBitItemWrapper qBitItemWrapper)
{
return !x.Tags.Any(tag =>
return !qBitItemWrapper.Tags.Any(tag =>
tag.Equals(downloadCleanerConfig.UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase));
}
@@ -62,71 +61,9 @@ public partial class QBitService
}
/// <inheritdoc/>
public override async Task CleanDownloadsAsync(List<ITorrentItem>? downloads, List<CleanCategory> categoriesToClean,
HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent)
{
if (downloads?.Count is null or 0)
{
return;
}
foreach (ITorrentItem download in downloads)
{
if (string.IsNullOrEmpty(download.Hash))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
if (download.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
CleanCategory? category = categoriesToClean
.FirstOrDefault(x => (download.Category ?? string.Empty).Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
continue;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!downloadCleanerConfig.DeletePrivate && download.IsPrivate)
{
_logger.LogDebug("skip | download is private | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, TimeSpan.FromSeconds(download.SeedingTimeSeconds), category);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
download.Name
);
await _eventPublisher.PublishDownloadCleaned(download.Ratio, TimeSpan.FromSeconds(download.SeedingTimeSeconds), category.Name, result.Reason);
}
await DeleteDownload(torrent.Hash);
}
public override async Task CreateCategoryAsync(string name)
@@ -143,7 +80,7 @@ public partial class QBitService
await _dryRunInterceptor.InterceptAsync(CreateCategory, name);
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItem>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
{
if (downloads?.Count is null or 0)
{
@@ -157,57 +94,36 @@ public partial class QBitService
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
}
foreach (ITorrentItem download in downloads)
foreach (QBitItemWrapper torrent in downloads.Cast<QBitItemWrapper>())
{
if (string.IsNullOrEmpty(download.Hash))
if (string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
if (download.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
// Get the underlying TorrentInfo to access SavePath and files
TorrentInfo? torrentInfo = await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = new[] { download.Hash } })
.ContinueWith(t => t.Result?.FirstOrDefault());
if (torrentInfo is null)
{
_logger.LogDebug("failed to find torrent info for {name}", download.Name);
continue;
}
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(download.Hash);
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(torrent.Hash);
if (files is null)
{
_logger.LogDebug("failed to find files for {name}", download.Name);
_logger.LogDebug("failed to find files for {name}", torrent.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
ContextProvider.Set("downloadName", torrent.Name);
ContextProvider.Set("hash", torrent.Hash);
bool hasHardlinks = false;
bool hasErrors = false;
foreach (TorrentContent file in files)
{
if (!file.Index.HasValue)
{
_logger.LogDebug("skip | file index is null for {name}", download.Name);
_logger.LogDebug("skip | file index is null for {name}", torrent.Name);
hasHardlinks = true;
break;
}
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrentInfo.SavePath, file.Name).Split(['\\', '/']));
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.SavePath, file.Name).Split(['\\', '/']));
if (file.Priority is TorrentContentPriority.Skip)
{
@@ -219,8 +135,8 @@ public partial class QBitService
if (hardlinkCount < 0)
{
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
hasHardlinks = true;
_logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath);
hasErrors = true;
break;
}
@@ -231,23 +147,29 @@ public partial class QBitService
}
}
if (hasHardlinks)
if (hasErrors)
{
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name);
continue;
}
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory, downloadCleanerConfig.UnlinkedUseTag);
await _dryRunInterceptor.InterceptAsync(ChangeCategory, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory, downloadCleanerConfig.UnlinkedUseTag);
if (downloadCleanerConfig.UnlinkedUseTag)
{
_logger.LogInformation("tag added for {name}", download.Name);
_logger.LogInformation("tag added for {name}", torrent.Name);
}
else
{
_logger.LogInformation("category changed for {name}", download.Name);
_logger.LogInformation("category changed for {name}", torrent.Name);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
}
}
}

View File

@@ -19,7 +19,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -40,11 +40,11 @@ public partial class QBitService
result.Found = true;
// Create ITorrentItem wrapper for consistent interface usage
var torrentItem = new QBitItem(download, trackers, result.IsPrivate);
QBitItemWrapper torrent = new(download, trackers, result.IsPrivate);
if (torrentItem.IsIgnored(ignoredDownloads))
if (torrent.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", torrentItem.Name);
_logger.LogInformation("skip | download is ignored | {name}", torrent.Name);
return result;
}
@@ -57,64 +57,64 @@ public partial class QBitService
// if all files were blocked by qBittorrent
if (download is { CompletionOn: not null, Downloaded: null or 0 })
{
_logger.LogDebug("all files are unwanted by qBit | removing download | {name}", torrentItem.Name);
_logger.LogDebug("all files are unwanted by qBit | removing download | {name}", torrent.Name);
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
result.DeleteFromClient = true;
return result;
}
// remove if all files are unwanted
_logger.LogDebug("all files are unwanted | removing download | {name}", torrentItem.Name);
_logger.LogDebug("all files are unwanted | removing download | {name}", torrent.Name);
result.DeleteReason = DeleteReason.AllFilesSkipped;
result.DeleteFromClient = true;
return result;
}
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrentItem);
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent);
return result;
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateDownloadRemoval(ITorrentItem torrentItem)
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) slowResult = await CheckIfSlow(torrentItem);
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) slowResult = await CheckIfSlow(wrapper);
if (slowResult.ShouldRemove)
{
return slowResult;
}
return await CheckIfStuck(torrentItem);
return await CheckIfStuck(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItem torrentItem)
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!torrentItem.IsDownloading())
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", torrentItem.Name);
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
if (torrentItem.DownloadSpeed <= 0)
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", torrentItem.Name);
_logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(torrentItem);
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItem torrentItem)
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (torrentItem.IsMetadataDownloading())
if (((QBitItemWrapper)wrapper).IsMetadataDownloading())
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.DownloadingMetadataMaxStrikes > 0)
{
bool shouldRemove = await _striker.StrikeAndCheckLimit(
torrentItem.Hash,
torrentItem.Name,
wrapper.Hash,
wrapper.Name,
queueCleanerConfig.DownloadingMetadataMaxStrikes,
StrikeType.DownloadingMetadata
);
@@ -125,12 +125,12 @@ public partial class QBitService
return (false, DeleteReason.None, false);
}
if (!torrentItem.IsStalled())
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", torrentItem.Name);
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(torrentItem);
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -0,0 +1,58 @@
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
/// <summary>
/// Concrete wrapper implementation for QBittorrentClient
/// </summary>
public sealed class QBittorrentClientWrapper : IQBittorrentClientWrapper
{
private readonly QBittorrentClient _client;
public QBittorrentClientWrapper(QBittorrentClient client)
{
_client = client;
}
public Task LoginAsync(string username, string password)
=> _client.LoginAsync(username, password);
public Task<ApiVersion> GetApiVersionAsync()
=> _client.GetApiVersionAsync();
public Task<IReadOnlyList<TorrentInfo>> GetTorrentListAsync(TorrentListQuery query)
=> _client.GetTorrentListAsync(query);
public Task<TorrentProperties?> GetTorrentPropertiesAsync(string hash)
=> _client.GetTorrentPropertiesAsync(hash);
public Task<IReadOnlyList<TorrentContent>> GetTorrentContentsAsync(string hash)
=> _client.GetTorrentContentsAsync(hash);
public Task<IReadOnlyList<TorrentTracker>> GetTorrentTrackersAsync(string hash)
=> _client.GetTorrentTrackersAsync(hash);
public Task<IReadOnlyDictionary<string, Category>> GetCategoriesAsync()
=> _client.GetCategoriesAsync();
public Task AddCategoryAsync(string category)
=> _client.AddCategoryAsync(category);
public Task DeleteAsync(IEnumerable<string> hashes, bool deleteDownloadedData)
=> _client.DeleteAsync(hashes, deleteDownloadedData);
public Task AddTorrentTagAsync(IEnumerable<string> hashes, string tag)
=> _client.AddTorrentTagAsync(hashes, tag);
public Task SetTorrentCategoryAsync(IEnumerable<string> hashes, string category)
=> _client.SetTorrentCategoryAsync(hashes, category);
public Task SetFilePriorityAsync(string hash, int fileIndex, TorrentContentPriority priority)
=> _client.SetFilePriorityAsync(hash, fileIndex, priority);
public Task SetPreferencesAsync(Preferences preferences)
=> _client.SetPreferencesAsync(preferences);
public void Dispose()
=> _client.Dispose();
}

View File

@@ -0,0 +1,16 @@
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
/// <summary>
/// Wrapper interface for Transmission Client to enable testing
/// </summary>
public interface ITransmissionClientWrapper
{
Task<SessionInfo> GetSessionInformationAsync();
Task<TransmissionTorrents?> TorrentGetAsync(string[] fields, string? hash = null);
Task TorrentSetAsync(TorrentSettings settings);
Task TorrentSetLocationAsync(long[] ids, string location, bool move);
Task TorrentRemoveAsync(long[] ids, bool deleteLocalData);
}

View File

@@ -0,0 +1,33 @@
using Transmission.API.RPC;
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
/// <summary>
/// Concrete wrapper implementation for Transmission Client
/// </summary>
public sealed class TransmissionClientWrapper : ITransmissionClientWrapper
{
private readonly Client _client;
public TransmissionClientWrapper(Client client)
{
_client = client;
}
public Task<SessionInfo> GetSessionInformationAsync()
=> _client.GetSessionInformationAsync();
public Task<TransmissionTorrents?> TorrentGetAsync(string[] fields, string? hash = null)
=> _client.TorrentGetAsync(fields, hash);
public Task TorrentSetAsync(TorrentSettings settings)
=> _client.TorrentSetAsync(settings);
public Task TorrentSetLocationAsync(long[] ids, string location, bool move)
=> _client.TorrentSetLocationAsync(ids, location, move);
public Task TorrentRemoveAsync(long[] ids, bool deleteLocalData)
=> _client.TorrentRemoveAsync(ids, deleteLocalData);
}

View File

@@ -1,126 +0,0 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Services;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
/// <summary>
/// Wrapper for Transmission TorrentInfo that implements ITorrentItem interface
/// </summary>
public sealed class TransmissionItem : ITorrentItem
{
private readonly TorrentInfo _torrentInfo;
public TransmissionItem(TorrentInfo torrentInfo)
{
_torrentInfo = torrentInfo ?? throw new ArgumentNullException(nameof(torrentInfo));
}
// Basic identification
public string Hash => _torrentInfo.HashString ?? string.Empty;
public string Name => _torrentInfo.Name ?? string.Empty;
// Privacy and tracking
public bool IsPrivate => _torrentInfo.IsPrivate ?? false;
public IReadOnlyList<string> Trackers => _torrentInfo.Trackers?
.Where(t => !string.IsNullOrEmpty(t.Announce))
.Select(t => ExtractHostFromUrl(t.Announce!))
.Where(host => !string.IsNullOrEmpty(host))
.Distinct()
.ToList()
.AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
// Size and progress
public long Size => _torrentInfo.TotalSize ?? 0;
public double CompletionPercentage => _torrentInfo.TotalSize > 0
? ((_torrentInfo.DownloadedEver ?? 0) / (double)_torrentInfo.TotalSize) * 100.0
: 0.0;
public long DownloadedBytes => _torrentInfo.DownloadedEver ?? 0;
public long TotalUploaded => _torrentInfo.UploadedEver ?? 0;
// Speed and transfer rates
public long DownloadSpeed => _torrentInfo.RateDownload ?? 0;
public long UploadSpeed => _torrentInfo.RateUpload ?? 0;
public double Ratio => (_torrentInfo.UploadedEver ?? 0) > 0 && (_torrentInfo.DownloadedEver ?? 0) > 0
? (_torrentInfo.UploadedEver ?? 0) / (double)(_torrentInfo.DownloadedEver ?? 1)
: 0.0;
// Time tracking
public long Eta => _torrentInfo.Eta ?? 0;
public DateTime? DateAdded => _torrentInfo.AddedDate.HasValue
? DateTimeOffset.FromUnixTimeSeconds(_torrentInfo.AddedDate.Value).DateTime
: null;
public DateTime? DateCompleted => _torrentInfo.DoneDate.HasValue && _torrentInfo.DoneDate.Value > 0
? DateTimeOffset.FromUnixTimeSeconds(_torrentInfo.DoneDate.Value).DateTime
: null;
public long SeedingTimeSeconds => _torrentInfo.SecondsSeeding ?? 0;
// Categories and tags
public string? Category => _torrentInfo.GetCategory();
public IReadOnlyList<string> Tags => _torrentInfo.Labels?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
// State checking methods
// Transmission status: 0=stopped, 1=check pending, 2=checking, 3=download pending, 4=downloading, 5=seed pending, 6=seeding
public bool IsDownloading() => _torrentInfo.Status == 4;
public bool IsStalled() => _torrentInfo is { Status: 4, RateDownload: <= 0, Eta: <= 0 };
public bool IsSeeding() => _torrentInfo.Status == 6;
public bool IsCompleted() => CompletionPercentage >= 100.0;
public bool IsPaused() => _torrentInfo.Status == 0;
public bool IsQueued() => _torrentInfo.Status is 1 or 3 or 5;
public bool IsChecking() => _torrentInfo.Status == 2;
public bool IsAllocating() => false; // Transmission doesn't have a specific allocating state
public bool IsMetadataDownloading() => false; // Transmission doesn't have this state
// Filtering methods
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{
if (ignoredDownloads.Count == 0)
{
return false;
}
foreach (string pattern in ignoredDownloads)
{
if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
bool? hasIgnoredTracker = _torrentInfo.Trackers?
.Any(x => UriService.GetDomain(x.Announce)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) ?? false);
if (hasIgnoredTracker is true)
{
return true;
}
}
return false;
}
/// <summary>
/// Extracts the host from a tracker URL
/// </summary>
private static string ExtractHostFromUrl(string url)
{
try
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return uri.Host;
}
}
catch
{
// Ignore parsing errors
}
return string.Empty;
}
}

View File

@@ -0,0 +1,84 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Services;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
/// <summary>
/// Wrapper for Transmission TorrentInfo that implements ITorrentItem interface
/// </summary>
public sealed class TransmissionItemWrapper : ITorrentItemWrapper
{
public TorrentInfo Info { get; }
public TransmissionItemWrapper(TorrentInfo torrentInfo)
{
Info = torrentInfo ?? throw new ArgumentNullException(nameof(torrentInfo));
}
public string Hash => Info.HashString ?? string.Empty;
public string Name => Info.Name ?? string.Empty;
public bool IsPrivate => Info.IsPrivate ?? false;
public long Size => Info.TotalSize ?? 0;
public double CompletionPercentage => Info.TotalSize > 0
? ((Info.DownloadedEver ?? 0) / (double)Info.TotalSize) * 100.0
: 0.0;
public long DownloadedBytes => Info.DownloadedEver ?? 0;
public long DownloadSpeed => Info.RateDownload ?? 0;
public double Ratio => (Info.UploadedEver ?? 0) > 0 && (Info.DownloadedEver ?? 0) > 0
? (Info.UploadedEver ?? 0) / (double)(Info.DownloadedEver ?? 1)
: 0.0;
public long Eta => Info.Eta ?? 0;
public long SeedingTimeSeconds => Info.SecondsSeeding ?? 0;
public string? Category
{
get => Info.GetCategory();
set => Info.AppendCategory(value);
}
// Transmission status: 0=stopped, 1=check pending, 2=checking, 3=download pending, 4=downloading, 5=seed pending, 6=seeding
public bool IsDownloading() => Info.Status == 4;
public bool IsStalled() => Info is { Status: 4, RateDownload: <= 0, Eta: <= 0 };
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{
if (ignoredDownloads.Count == 0)
{
return false;
}
foreach (string pattern in ignoredDownloads)
{
if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
bool? hasIgnoredTracker = Info.Trackers?
.Any(x => UriService.GetDomain(x.Announce)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) ?? false);
if (hasIgnoredTracker is true)
{
return true;
}
}
return false;
}
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
@@ -15,7 +16,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
public partial class TransmissionService : DownloadService, ITransmissionService
{
private readonly Client _client;
private readonly ITransmissionClientWrapper _client;
private static readonly string[] Fields =
[
@@ -44,7 +45,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
EventPublisher eventPublisher,
IEventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
@@ -57,12 +58,37 @@ public partial class TransmissionService : DownloadService, ITransmissionService
{
UriBuilder uriBuilder = new(_downloadClientConfig.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/rpc";
_client = new Client(
var client = new Client(
_httpClient,
uriBuilder.Uri.ToString(),
login: _downloadClientConfig.Username,
password: _downloadClientConfig.Password
);
_client = new TransmissionClientWrapper(client);
}
// Internal constructor for testing
internal TransmissionService(
ILogger<TransmissionService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
IEventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
ITransmissionClientWrapper clientWrapper
) : base(
logger, cache,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
_client = clientWrapper;
}
public override async Task LoginAsync()

Some files were not shown because too many files have changed in this diff Show More