From b343165644317142d36aae7dd9ca89fd934bc3ba Mon Sep 17 00:00:00 2001 From: Flaminel Date: Wed, 10 Dec 2025 09:22:51 +0200 Subject: [PATCH] Fix Download Cleaner making too many requests (#368) --- .github/workflows/build-docker.yml | 2 +- .../Controllers/StatusController.cs | 8 +- .../DependencyInjection/ServicesDI.cs | 26 +- ...ITorrentItem.cs => ITorrentItemWrapper.cs} | 31 +- .../Cleanuparr.Infrastructure.Tests.csproj | 11 +- .../EventCleanupServiceIntegrationTests.cs | 130 ++ .../Events/EventCleanupServiceTests.cs | 130 ++ .../Events/EventPublisherTests.cs | 523 +++++++++ .../Features/Arr/ArrClientFactoryTests.cs | 132 +++ .../BlacklistSynchronizerTests.cs | 365 ++++++ .../DownloadClient/DelugeItemTests.cs | 269 ----- .../DownloadClient/DelugeItemWrapperTests.cs | 453 +++++++ .../DownloadClient/DelugeServiceDCTests.cs | 772 ++++++++++++ .../DownloadClient/DelugeServiceFixture.cs | 118 ++ .../DownloadClient/DelugeServiceTests.cs | 499 ++++++++ .../DownloadServiceFactoryTests.cs | 281 +++++ ...itItemTests.cs => QBitItemWrapperTests.cs} | 453 ++++--- .../DownloadClient/QBitServiceDCTests.cs | 1043 ++++++++++++++++ .../DownloadClient/QBitServiceFixture.cs | 121 ++ .../DownloadClient/QBitServiceTests.cs | 1024 ++++++++++++++++ .../TestHelpers/TestBlocklistProvider.cs | 25 + .../DownloadClient/TransmissionItemTests.cs | 239 ---- .../TransmissionItemWrapperTests.cs | 307 +++++ .../TransmissionServiceDCTests.cs | 906 ++++++++++++++ .../TransmissionServiceFixture.cs | 118 ++ .../TransmissionServiceTests.cs | 718 ++++++++++++ .../UTorrentItemWrapperTests.cs | 170 ++- .../DownloadClient/UTorrentServiceDCTests.cs | 724 ++++++++++++ .../DownloadClient/UTorrentServiceFixture.cs | 118 ++ .../DownloadClient/UTorrentServiceTests.cs | 613 ++++++++++ .../Consumers/DownloadHunterConsumerTests.cs | 164 +++ .../DownloadHunter/DownloadHunterTests.cs | 311 +++++ .../Consumers/DownloadRemoverConsumerTests.cs | 227 ++++ .../DownloadRemover/QueueItemRemoverTests.cs | 484 ++++++++ .../Features/Jobs/DownloadCleanerTests.cs | 916 +++++++++++++++ .../Features/Jobs/MalwareBlockerTests.cs | 609 ++++++++++ .../Features/Jobs/QueueCleanerTests.cs | 1044 +++++++++++++++++ .../Jobs/TestHelpers/JobHandlerCollection.cs | 13 + .../Jobs/TestHelpers/JobHandlerFixture.cs | 130 ++ .../TestHelpers/TestDataContextFactory.cs | 335 ++++++ .../MalwareBlocker/BlocklistProviderTests.cs | 187 +++ .../Apprise/AppriseProxyTests.cs | 291 +++++ .../Notifications/AppriseProviderTests.cs | 203 ++++ .../Notifiarr/NotifiarrProxyTests.cs | 283 +++++ .../Notifications/NotifiarrProviderTests.cs | 267 +++++ .../NotificationConfigurationServiceTests.cs | 345 ++++++ .../NotificationConsumerTests.cs | 559 +++++++++ .../NotificationProviderFactoryTests.cs | 257 ++++ .../NotificationPublisherTests.cs | 597 ++++++++++ .../Notifications/NotificationServiceTests.cs | 354 ++++++ .../Notifications/Ntfy/NtfyProxyTests.cs | 344 ++++++ .../Notifications/NtfyProviderTests.cs | 301 +++++ .../QueueCleaner/QueueRuleMatchTests.cs | 5 +- .../Health/ApplicationHealthCheckTests.cs | 98 ++ .../Health/DatabaseHealthCheckTests.cs | 122 ++ .../Health/DownloadClientsHealthCheckTests.cs | 242 ++++ .../HealthCheckBackgroundServiceTests.cs | 345 ++++++ .../Health/HealthCheckServiceFixture.cs | 91 -- .../Health/HealthCheckServiceTests.cs | 177 --- .../Models/ValidationResultTests.cs | 148 +++ .../Services/AppStatusRefreshServiceTests.cs | 178 +++ .../Services/JobManagementServiceTests.cs | 577 +++++++++ .../Services/RuleEvaluatorTests.cs | 66 +- .../Services/RuleManagerTests.cs | 5 +- .../Services/StrikerTests.cs | 339 ++++++ .../Utilities/CronExpressionConverterTests.cs | 164 +++ .../Utilities/CronValidationHelperTests.cs | 136 +++ .../Utilities/ScheduleOptionsTests.cs | 183 +++ .../xunit.runner.json | 9 + .../Events/EventPublisher.cs | 3 +- .../Events/Interfaces/IEventPublisher.cs | 22 + .../Extensions/TransmissionExtensions.cs | 34 +- .../Features/Arr/ArrClientFactory.cs | 12 +- .../Features/Arr/ArrQueueIterator.cs | 2 +- .../Arr/Interfaces/IArrClientFactory.cs | 8 + .../Arr/Interfaces/IArrQueueIterator.cs | 9 + .../BlacklistSync/BlacklistSynchronizer.cs | 4 +- .../Deluge/DelugeClientWrapper.cs | 52 + .../DownloadClient/Deluge/DelugeItem.cs | 115 -- .../Deluge/DelugeItemWrapper.cs | 78 ++ .../DownloadClient/Deluge/DelugeService.cs | 32 +- .../DownloadClient/Deluge/DelugeServiceDC.cs | 139 +-- .../DownloadClient/Deluge/DelugeServiceQC.cs | 43 +- .../Deluge/IDelugeClientWrapper.cs | 20 + .../DownloadClient/DownloadService.cs | 87 +- .../DownloadClient/DownloadServiceFactory.cs | 11 +- .../DownloadClient/IDownloadService.cs | 17 +- .../DownloadClient/IDownloadServiceFactory.cs | 8 + .../QBittorrent/IQBittorrentClientWrapper.cs | 23 + .../DownloadClient/QBittorrent/QBitItem.cs | 123 -- .../QBittorrent/QBitItemWrapper.cs | 92 ++ .../DownloadClient/QBittorrent/QBitService.cs | 31 +- .../QBittorrent/QBitServiceDC.cs | 148 +-- .../QBittorrent/QBitServiceQC.cs | 46 +- .../QBittorrent/QBittorrentClientWrapper.cs | 58 + .../ITransmissionClientWrapper.cs | 16 + .../Transmission/TransmissionClientWrapper.cs | 33 + .../Transmission/TransmissionItem.cs | 126 -- .../Transmission/TransmissionItemWrapper.cs | 84 ++ .../Transmission/TransmissionService.cs | 32 +- .../Transmission/TransmissionServiceDC.cs | 158 +-- .../Transmission/TransmissionServiceQC.cs | 41 +- .../UTorrent/IUTorrentClientWrapper.cs | 17 + .../DownloadClient/UTorrent/UTorrentClient.cs | 12 - .../UTorrent/UTorrentClientWrapper.cs | 43 + .../UTorrent/UTorrentItemWrapper.cs | 106 +- .../UTorrent/UTorrentService.cs | 34 +- .../UTorrent/UTorrentServiceDC.cs | 152 +-- .../UTorrent/UTorrentServiceQC.cs | 38 +- .../Features/DownloadHunter/DownloadHunter.cs | 12 +- .../DownloadRemover/QueueItemRemover.cs | 10 +- .../Features/ItemStriker/Striker.cs | 5 +- .../Features/Jobs/DownloadCleaner.cs | 261 +++-- .../Features/Jobs/GenericHandler.cs | 20 +- .../Features/Jobs/JobChainingListener.cs | 37 - .../Features/Jobs/MalwareBlocker.cs | 15 +- .../Features/Jobs/QueueCleaner.cs | 13 +- .../MalwareBlocker/BlocklistProvider.cs | 2 +- .../MalwareBlocker/IBlocklistProvider.cs | 18 + .../Consumers/NotificationConsumer.cs | 6 +- .../Features/Security/AesEncryptionService.cs | 129 -- .../Features/Security/IEncryptionService.cs | 29 - .../Security/SensitiveDataJsonConverter.cs | 88 -- .../Health/HealthCheckService.cs | 2 +- .../Properties/AssemblyInfo.cs | 3 + .../Services/Interfaces/IRuleEvaluator.cs | 4 +- .../Services/Interfaces/IRuleManager.cs | 4 +- .../Services/RuleEvaluator.cs | 10 +- .../Services/RuleManager.cs | 6 +- .../Utilities/CronExpressionConverter.cs | 76 +- .../Configuration/QueueCleaner/IQueueRule.cs | 2 +- .../Configuration/QueueCleaner/QueueRule.cs | 2 +- .../Configuration/QueueCleaner/SlowRule.cs | 2 +- .../Configuration/QueueCleaner/StallRule.cs | 2 +- 134 files changed, 21645 insertions(+), 2657 deletions(-) rename code/backend/Cleanuparr.Domain/Entities/{ITorrentItem.cs => ITorrentItemWrapper.cs} (59%) create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceIntegrationTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientFactoryTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/BlacklistSync/BlacklistSynchronizerTests.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceFixture.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs rename code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/{QBitItemTests.cs => QBitItemWrapperTests.cs} (52%) create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceFixture.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TestHelpers/TestBlocklistProvider.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceFixture.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceDCTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceFixture.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/Consumers/DownloadHunterConsumerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/DownloadHunterTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerCollection.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/MalwareBlocker/BlocklistProviderTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseProxyTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/AppriseProviderTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Notifiarr/NotifiarrProxyTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotifiarrProviderTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConsumerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Ntfy/NtfyProxyTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NtfyProviderTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Health/ApplicationHealthCheckTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Health/DatabaseHealthCheckTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Health/DownloadClientsHealthCheckTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckBackgroundServiceTests.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckServiceFixture.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Models/ValidationResultTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Services/AppStatusRefreshServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Services/JobManagementServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Services/StrikerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Utilities/CronExpressionConverterTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Utilities/CronValidationHelperTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Utilities/ScheduleOptionsTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/xunit.runner.json create mode 100644 code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClientFactory.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrQueueIterator.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeClientWrapper.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItem.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/IDelugeClientWrapper.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadServiceFactory.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/IQBittorrentClientWrapper.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItem.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBittorrentClientWrapper.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/ITransmissionClientWrapper.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionClientWrapper.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItem.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/IUTorrentClientWrapper.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClientWrapper.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Jobs/JobChainingListener.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/IBlocklistProvider.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Security/AesEncryptionService.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Security/IEncryptionService.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Security/SensitiveDataJsonConverter.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Properties/AssemblyInfo.cs diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 940149d9..10a7f4a7 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -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 diff --git a/code/backend/Cleanuparr.Api/Controllers/StatusController.cs b/code/backend/Cleanuparr.Api/Controllers/StatusController.cs index b79348f4..a1e206d0 100644 --- a/code/backend/Cleanuparr.Api/Controllers/StatusController.cs +++ b/code/backend/Cleanuparr.Api/Controllers/StatusController.cs @@ -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 _logger; private readonly DataContext _dataContext; - private readonly DownloadServiceFactory _downloadServiceFactory; - private readonly ArrClientFactory _arrClientFactory; + private readonly IArrClientFactory _arrClientFactory; public StatusController( ILogger logger, DataContext dataContext, - DownloadServiceFactory downloadServiceFactory, - ArrClientFactory arrClientFactory) + IArrClientFactory arrClientFactory) { _logger = logger; _dataContext = dataContext; - _downloadServiceFactory = downloadServiceFactory; _arrClientFactory = arrClientFactory; } diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index 09be8877..23ed7181 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -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() - .AddScoped() .AddScoped() .AddScoped() - .AddScoped() + .AddScoped() .AddHostedService() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -47,15 +46,16 @@ public static class ServicesDI .AddScoped() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddSingleton() - .AddSingleton() + .AddSingleton() + .AddSingleton(TimeProvider.System) .AddSingleton() .AddHostedService(); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/ITorrentItem.cs b/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs similarity index 59% rename from code/backend/Cleanuparr.Domain/Entities/ITorrentItem.cs rename to code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs index 95d50c33..900445fc 100644 --- a/code/backend/Cleanuparr.Domain/Entities/ITorrentItem.cs +++ b/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs @@ -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. /// -public interface ITorrentItem +public interface ITorrentItemWrapper { - // Basic identification string Hash { get; } + string Name { get; } - // Privacy and tracking bool IsPrivate { get; } - IReadOnlyList 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 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 /// /// Determines if this torrent should be ignored based on the provided patterns. /// Checks if any pattern matches the torrent name, hash, or tracker. diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Cleanuparr.Infrastructure.Tests.csproj b/code/backend/Cleanuparr.Infrastructure.Tests/Cleanuparr.Infrastructure.Tests.csproj index 017de59a..01f085dc 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Cleanuparr.Infrastructure.Tests.csproj +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Cleanuparr.Infrastructure.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -6,6 +6,10 @@ enable + + + + @@ -17,6 +21,7 @@ + @@ -26,6 +31,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceIntegrationTests.cs new file mode 100644 index 00000000..f36042be --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceIntegrationTests.cs @@ -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; + +/// +/// Integration tests for the cleanup logic that actually deletes events +/// +public class EventCleanupServiceIntegrationTests : IDisposable +{ + private readonly EventsContext _context; + private readonly Mock> _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(options => + options.UseInMemoryDatabase(databaseName: _dbName)); + + _serviceProvider = services.BuildServiceProvider(); + _loggerMock = new Mock>(); + + using var scope = _serviceProvider.CreateScope(); + _context = scope.ServiceProvider.GetRequiredService(); + } + + public void Dispose() + { + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + 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(); + + 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(); + var count = await context.Events.CountAsync(); + Assert.Equal(2, count); + } + } + + [Fact] + public async Task EventCleanupService_CanStartAndStop() + { + // Arrange + var scopeFactory = _serviceProvider.GetRequiredService(); + 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(); + 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(), + It.Is((v, t) => v.ToString()!.Contains("Failed to perform event cleanup")), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs new file mode 100644 index 00000000..6759f9ab --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs @@ -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> _loggerMock; + private readonly ServiceCollection _services; + private readonly IServiceProvider _serviceProvider; + private readonly string _dbName; + + public EventCleanupServiceTests() + { + _loggerMock = new Mock>(); + _services = new ServiceCollection(); + _dbName = Guid.NewGuid().ToString(); + + // Setup in-memory database for testing + _services.AddDbContext(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(); + context.Database.EnsureDeleted(); + } + + [Fact] + public async Task ExecuteAsync_LogsStartMessage() + { + // Arrange + var scopeFactory = _serviceProvider.GetRequiredService(); + 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(), + It.Is((v, t) => v.ToString()!.Contains("started")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task StopAsync_LogsStopMessage() + { + // Arrange + var scopeFactory = _serviceProvider.GetRequiredService(); + 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(), + It.Is((v, t) => v.ToString()!.Contains("stopping")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public void Constructor_InitializesWithCorrectParameters() + { + // Arrange + var scopeFactory = _serviceProvider.GetRequiredService(); + + // 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(); + 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(), + It.Is((v, t) => v.ToString()!.Contains("stopped")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs new file mode 100644 index 00000000..9184c1ed --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs @@ -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> _hubContextMock; + private readonly Mock> _loggerMock; + private readonly Mock _notificationPublisherMock; + private readonly Mock _dryRunInterceptorMock; + private readonly Mock _clientProxyMock; + private readonly EventPublisher _publisher; + + public EventPublisherTests() + { + // Setup in-memory database + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new EventsContext(options); + + // Setup mocks + _hubContextMock = new Mock>(); + _loggerMock = new Mock>(); + _notificationPublisherMock = new Mock(); + _dryRunInterceptorMock = new Mock(); + _clientProxyMock = new Mock(); + + // Setup HubContext to return client proxy + var clientsMock = new Mock(); + 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(), It.IsAny())) + .Returns(async (del, args) => + { + if (del is Func func && args.Length > 0 && args[0] is AppEvent appEvent) + { + await func(appEvent); + } + else if (del is Func 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(args => args.Length == 1 && args[0] is AppEvent), + It.IsAny()), 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(), + It.IsAny(), + It.IsAny())) + .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(args => args.Length == 1 && args[0] is ManualEvent), + It.IsAny()), 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(), + It.IsAny()), 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(), + It.IsAny()), 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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientFactoryTests.cs new file mode 100644 index 00000000..00ae909b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientFactoryTests.cs @@ -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 _sonarrClientMock; + private readonly Mock _radarrClientMock; + private readonly Mock _lidarrClientMock; + private readonly Mock _readarrClientMock; + private readonly Mock _whisparrClientMock; + private readonly ArrClientFactory _factory; + + public ArrClientFactoryTests() + { + _sonarrClientMock = new Mock(); + _radarrClientMock = new Mock(); + _lidarrClientMock = new Mock(); + _readarrClientMock = new Mock(); + _whisparrClientMock = new Mock(); + + _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(() => _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(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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/BlacklistSync/BlacklistSynchronizerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/BlacklistSync/BlacklistSynchronizerTests.cs new file mode 100644 index 00000000..334ae53a --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/BlacklistSync/BlacklistSynchronizerTests.cs @@ -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> _loggerMock; + private readonly DataContext _dataContext; + private readonly Mock _downloadServiceFactoryMock; + private readonly Mock _dryRunInterceptorMock; + private readonly FileReader _fileReader; + private readonly BlacklistSynchronizer _synchronizer; + private readonly Mock _httpMessageHandlerMock; + private readonly SqliteConnection _connection; + + public BlacklistSynchronizerTests() + { + _loggerMock = new Mock>(); + + // Use SQLite in-memory with shared connection to support complex types + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _dataContext = new DataContext(options); + _dataContext.Database.EnsureCreated(); + + _downloadServiceFactoryMock = new Mock(); + + _dryRunInterceptorMock = new Mock(); + // Setup interceptor to execute the action with params using DynamicInvoke + _dryRunInterceptorMock + .Setup(d => d.InterceptAsync(It.IsAny(), It.IsAny())) + .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(); + var httpClient = new HttpClient(_httpMessageHandlerMock.Object); + + var httpClientFactoryMock = new Mock(); + httpClientFactoryMock + .Setup(f => f.CreateClient(It.IsAny())) + .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()), + Times.Never); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("disabled")), + It.IsAny(), + It.IsAny>()), + 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()), + Times.Never); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("path is not configured")), + It.IsAny(), + It.IsAny>()), + 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()), + Times.Never); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("path is not configured")), + It.IsAny(), + It.IsAny>()), + 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(), + It.Is((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")), + It.IsAny(), + It.IsAny>()), + 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(), + It.Is((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")), + It.IsAny(), + It.IsAny>()), + 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(), + It.Is((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")), + It.IsAny(), + It.IsAny>()), + 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()), + Times.Never); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("already synced")), + It.IsAny(), + It.IsAny>()), + 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) + _dryRunInterceptorMock.Verify( + d => d.InterceptAsync(It.IsAny(), It.IsAny()), + 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 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemTests.cs deleted file mode 100644 index 3eeac643..00000000 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemTests.cs +++ /dev/null @@ -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(() => new DelugeItem(null!)); - } - - [Fact] - public void Hash_ReturnsCorrectValue() - { - // Arrange - var expectedHash = "test-hash-123"; - var downloadStatus = new DownloadStatus - { - Hash = expectedHash, - Trackers = new List(), - 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(), - 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(), - 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(), - 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(), - 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(), - 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(), - 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 - { - 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 - { - 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 - { - 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(), - 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(); - } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs new file mode 100644 index 00000000..e5c75502 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs @@ -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(() => new DelugeItemWrapper(null!)); + } + + [Fact] + public void Hash_ReturnsCorrectValue() + { + // Arrange + var expectedHash = "test-hash-123"; + var downloadStatus = new DownloadStatus + { + Hash = expectedHash, + Trackers = new List(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + DownloadLocation = "/test/path" + }; + var wrapper = new DelugeItemWrapper(downloadStatus); + + // Act + var result = wrapper.IsIgnored(Array.Empty()); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsIgnored_MatchingHash_ReturnsTrue() + { + // Arrange + var downloadStatus = new DownloadStatus + { + Hash = "abc123", + Name = "Test Torrent", + Trackers = new List(), + 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(), + 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 + { + 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 + { + 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(), + 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(), + 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(), + 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(), + DownloadLocation = "/test/path" + }; + var wrapper = new DelugeItemWrapper(downloadStatus); + + // Act + var result = wrapper.IsStalled(); + + // Assert + result.ShouldBe(expected); + } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs new file mode 100644 index 00000000..0af7682b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs @@ -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 +{ + 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 + { + new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "Seeding", Private = false, Trackers = new List(), DownloadLocation = "/downloads" }, + new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "Downloading", Private = false, Trackers = new List(), DownloadLocation = "/downloads" }, + new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = "Seeding", Private = false, Trackers = new List(), 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 + { + new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "SEEDING", Private = false, Trackers = new List(), DownloadLocation = "/downloads" }, + new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "seeding", Private = false, Trackers = new List(), 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?)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 + { + new DownloadStatus { Hash = "", Name = "No Hash", State = "Seeding", Private = false, Trackers = new List(), DownloadLocation = "/downloads" }, + new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = "Seeding", Private = false, Trackers = new List(), 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }), + new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List(), DownloadLocation = "/downloads" }), + new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + var categories = new List + { + 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + var categories = new List + { + 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + var categories = new List + { + 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }), + new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "movies" }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public void SkipsDownloadsWithEmptyHash() + { + // Arrange + var sut = _fixture.CreateSut(); + + var downloads = new List + { + new DelugeItemWrapper(new DownloadStatus { Hash = "", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }), + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "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()); + + _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 { "existing" }); + + // Act + await sut.CreateCategoryAsync("existing"); + + // Assert + _fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny()), Times.Never); + } + + [Fact] + public async Task IsCaseInsensitive() + { + // Arrange + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper + .Setup(x => x.GetLabels()) + .ReturnsAsync(new List { "Existing" }); + + // Act + await sut.CreateCategoryAsync("existing"); + + // Assert + _fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny()), 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>(h => h.Contains("test-hash")))) + .Returns(Task.CompletedTask); + + // Act + await sut.DeleteDownload(hash); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.DeleteTorrents(It.Is>(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>())) + .Returns(Task.CompletedTask); + + // Act + await sut.DeleteDownload(hash); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.DeleteTorrents(It.Is>(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(), It.IsAny()), 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()); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny(), It.IsAny()), 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny(), It.IsAny()), 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny(), It.IsAny()), 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny(), It.IsAny()), 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), 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(), It.IsAny()), 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles("hash1")) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } + } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles("hash1")) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } + } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(2); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny(), It.IsAny()), 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles("hash1")) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } + } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(-1); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny(), It.IsAny()), 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles("hash1")) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "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(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.HardLinkFileService.Verify( + x => x.GetHardLinkCount(It.IsAny(), It.IsAny()), + 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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles("hash1")) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } + } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .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 + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles("hash1")) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } + } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .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); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceFixture.cs new file mode 100644 index 00000000..24558b10 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceFixture.cs @@ -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> Logger { get; } + public MemoryCache Cache { get; } + public Mock FilenameEvaluator { get; } + public Mock Striker { get; } + public Mock DryRunInterceptor { get; } + public Mock HardLinkFileService { get; } + public Mock HttpClientProvider { get; } + public Mock EventPublisher { get; } + public BlocklistProvider BlocklistProvider { get; } + public Mock RuleEvaluator { get; } + public Mock RuleManager { get; } + public Mock ClientWrapper { get; } + + public DelugeServiceFixture() + { + Logger = new Mock>(); + Cache = new MemoryCache(new MemoryCacheOptions()); + FilenameEvaluator = new Mock(); + Striker = new Mock(); + DryRunInterceptor = new Mock(); + HardLinkFileService = new Mock(); + HttpClientProvider = new Mock(); + EventPublisher = new Mock(); + BlocklistProvider = TestBlocklistProviderFactory.Create(); + RuleEvaluator = new Mock(); + RuleManager = new Mock(); + ClientWrapper = new Mock(); + + DryRunInterceptor + .Setup(x => x.InterceptAsync(It.IsAny(), It.IsAny())) + .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())) + .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(), It.IsAny())) + .Returns((Delegate action, object[] parameters) => + { + return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); + }); + } + + public void Dispose() + { + Cache.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs new file mode 100644 index 00000000..b8beb6c5 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs @@ -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 +{ + 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()); + + 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(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentStatus(hash)) + .ReturnsAsync(downloadStatus); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles(hash)) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } } + } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentStatus(hash)) + .ReturnsAsync(downloadStatus); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles(hash)) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } } + } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentStatus(hash)) + .ReturnsAsync(downloadStatus); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles(hash)) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "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()); + + 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(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentStatus(hash)) + .ReturnsAsync(downloadStatus); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles(hash)) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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(), + 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(), + 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 + { + 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(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentStatus(hash)) + .ReturnsAsync(downloadStatus); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles(hash)) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } } + } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny()), 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(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentStatus(hash)) + .ReturnsAsync(downloadStatus); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles(hash)) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } } + } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny()), 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(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentStatus(hash)) + .ReturnsAsync(downloadStatus); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles(hash)) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } } + } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((true, DeleteReason.SlowSpeed, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentStatus(hash)) + .ReturnsAsync(downloadStatus); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFiles(hash)) + .ReturnsAsync(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } } + } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((true, DeleteReason.Stalled, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.Stalled, result.DeleteReason); + Assert.True(result.DeleteFromClient); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs new file mode 100644 index 00000000..dbcbec7c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs @@ -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> _loggerMock; + private readonly IServiceProvider _serviceProvider; + private readonly DownloadServiceFactory _factory; + private readonly MemoryCache _memoryCache; + + public DownloadServiceFactoryTests() + { + _loggerMock = new Mock>(); + + var services = new ServiceCollection(); + + // Use real MemoryCache - mocks don't work properly with cache operations + _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + services.AddSingleton(_memoryCache); + + // Register loggers + services.AddSingleton(Mock.Of>()); + services.AddSingleton(Mock.Of>()); + services.AddSingleton(Mock.Of>()); + services.AddSingleton(Mock.Of>()); + + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + + // IDynamicHttpClientProvider must return a real HttpClient for download services + var httpClientProviderMock = new Mock(); + httpClientProviderMock.Setup(p => p.CreateClient(It.IsAny())).Returns(new HttpClient()); + services.AddSingleton(httpClientProviderMock.Object); + + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + + // UTorrentService needs ILoggerFactory + services.AddLogging(); + + // EventPublisher requires specific constructor arguments + var eventsContextOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var eventsContext = new EventsContext(eventsContextOptions); + var hubContextMock = new Mock>(); + var clientsMock = new Mock(); + clientsMock.Setup(c => c.All).Returns(Mock.Of()); + hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object); + + services.AddSingleton(new EventPublisher( + eventsContext, + hubContextMock.Object, + Mock.Of>(), + Mock.Of(), + Mock.Of())); + + // BlocklistProvider requires specific constructor arguments + var scopeFactoryMock = new Mock(); + + services.AddSingleton(new BlocklistProvider( + Mock.Of>(), + 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(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(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(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(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(() => _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(), + It.Is((v, t) => v.ToString()!.Contains("disabled")), + It.IsAny(), + It.IsAny>()), + 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(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + 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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs similarity index 52% rename from code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemTests.cs rename to code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs index 226b55a8..034e18fb 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs @@ -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(); // Act & Assert - Should.Throw(() => new QBitItem(null!, trackers, false)); + Should.Throw(() => new QBitItemWrapper(null!, trackers, false)); } [Fact] @@ -24,7 +24,7 @@ public class QBitItemTests var torrentInfo = new TorrentInfo(); // Act & Assert - Should.Throw(() => new QBitItem(torrentInfo, null!, false)); + Should.Throw(() => 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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 - { - 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 - { - 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 - { - 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(); - 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 { "tag1", "tag2", "tag3" }; + var torrentInfo = new TorrentInfo { Tags = expectedTags.AsReadOnly() }; + var trackers = new List(); + 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(); + 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().AsReadOnly() }; + var trackers = new List(); + 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(); + 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(); + 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - var wrapper = new QBitItem(torrentInfo, trackers, false); + var wrapper = new QBitItemWrapper(torrentInfo, trackers, false); // Act var result = wrapper.IsIgnored(Array.Empty()); @@ -430,7 +459,7 @@ public class QBitItemTests // Arrange var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" }; var trackers = new List(); - 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 { "test-tag" }.AsReadOnly() + }; + var trackers = new List(); + 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(); + 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 { 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 { "some-tag" }.AsReadOnly() + }; var trackers = new List { 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 diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs new file mode 100644 index 00000000..0c717ea1 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs @@ -0,0 +1,1043 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Moq; +using QBittorrent.Client; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; + +public class QBitServiceDCTests : IClassFixture +{ + private readonly QBitServiceFixture _fixture; + + public QBitServiceDCTests(QBitServiceFixture fixture) + { + _fixture = fixture; + _fixture.ResetMocks(); + } + + public class GetSeedingDownloads_Tests : QBitServiceDCTests + { + public GetSeedingDownloads_Tests(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task ReturnsCompletedTorrents() + { + // Arrange + var sut = _fixture.CreateSut(); + + var torrentList = new[] + { + new TorrentInfo { Hash = "hash1", Name = "Torrent 1", State = TorrentState.Uploading }, + new TorrentInfo { Hash = "hash2", Name = "Torrent 2", State = TorrentState.Uploading } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Filter == TorrentListFilter.Completed))) + .ReturnsAsync(torrentList); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync("hash1")) + .ReturnsAsync(Array.Empty()); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync("hash2")) + .ReturnsAsync(Array.Empty()); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(It.IsAny())) + .ReturnsAsync(new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(false) } + } + }); + + // Act + var result = await sut.GetSeedingDownloads(); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, item => Assert.NotNull(item.Hash)); + } + + [Fact] + public async Task SetsIsPrivateCorrectly_WhenPrivate() + { + // Arrange + var sut = _fixture.CreateSut(); + + var torrentList = new[] + { + new TorrentInfo { Hash = "hash1", Name = "Private Torrent", State = TorrentState.Uploading } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Filter == TorrentListFilter.Completed))) + .ReturnsAsync(torrentList); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync("hash1")) + .ReturnsAsync(Array.Empty()); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync("hash1")) + .ReturnsAsync(new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(true) } + } + }); + + // Act + var result = await sut.GetSeedingDownloads(); + + // Assert + Assert.Single(result); + Assert.True(result[0].IsPrivate); + } + + [Fact] + public async Task SetsIsPrivateCorrectly_WhenPublic() + { + // Arrange + var sut = _fixture.CreateSut(); + + var torrentList = new[] + { + new TorrentInfo { Hash = "hash1", Name = "Public Torrent", State = TorrentState.Uploading } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Filter == TorrentListFilter.Completed))) + .ReturnsAsync(torrentList); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync("hash1")) + .ReturnsAsync(Array.Empty()); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync("hash1")) + .ReturnsAsync(new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(false) } + } + }); + + // Act + var result = await sut.GetSeedingDownloads(); + + // Assert + Assert.Single(result); + Assert.False(result[0].IsPrivate); + } + + [Fact] + public async Task ReturnsEmptyList_WhenNoTorrents() + { + // Arrange + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Filter == TorrentListFilter.Completed))) + .ReturnsAsync((TorrentInfo[]?)null); + + // Act + var result = await sut.GetSeedingDownloads(); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task SkipsTorrentsWithEmptyHash() + { + // Arrange + var sut = _fixture.CreateSut(); + + var torrentList = new[] + { + new TorrentInfo { Hash = "", Name = "No Hash", State = TorrentState.Uploading }, + new TorrentInfo { Hash = "hash1", Name = "Valid Hash", State = TorrentState.Uploading } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Filter == TorrentListFilter.Completed))) + .ReturnsAsync(torrentList); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync("hash1")) + .ReturnsAsync(Array.Empty()); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync("hash1")) + .ReturnsAsync(new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(false) } + } + }); + + // Act + var result = await sut.GetSeedingDownloads(); + + // Assert + Assert.Single(result); + Assert.Equal("hash1", result[0].Hash); + } + } + + public class FilterDownloadsToBeCleanedAsync_Tests : QBitServiceDCTests + { + public FilterDownloadsToBeCleanedAsync_Tests(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public void MatchesCategories() + { + // Arrange + var sut = _fixture.CreateSut(); + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty(), false), + new QBitItemWrapper(new TorrentInfo { Hash = "hash2", Category = "tv" }, Array.Empty(), false), + new QBitItemWrapper(new TorrentInfo { Hash = "hash3", Category = "music" }, Array.Empty(), false) + }; + + var categories = new List + { + 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 + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "Movies" }, Array.Empty(), false) + }; + + var categories = new List + { + 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 SkipsDownloadsWithEmptyHash() + { + // Arrange + var sut = _fixture.CreateSut(); + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "", Category = "movies" }, Array.Empty(), false), + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty(), false) + }; + + var categories = new List + { + new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 } + }; + + // Act + var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("hash1", result[0].Hash); + } + + [Fact] + public void ReturnsEmptyList_WhenNoMatches() + { + // Arrange + var sut = _fixture.CreateSut(); + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "music" }, Array.Empty(), false) + }; + + var categories = new List + { + 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 : QBitServiceDCTests + { + public FilterDownloadsToChangeCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public void ExcludesAlreadyTagged_WhenTagModeEnabled() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = true, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var torrentInfo1 = new TorrentInfo { Hash = "hash1", Category = "movies", Tags = new[] { "unlinked" } }; + var torrentInfo2 = new TorrentInfo { Hash = "hash2", Category = "movies", Tags = Array.Empty() }; + + var downloads = new List + { + new QBitItemWrapper(torrentInfo1, Array.Empty(), false), + new QBitItemWrapper(torrentInfo2, Array.Empty(), false) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "movies" }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("hash2", result[0].Hash); + } + + [Fact] + public void IncludesAll_WhenCategoryModeEnabled() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty(), false), + new QBitItemWrapper(new TorrentInfo { Hash = "hash2", Category = "movies" }, Array.Empty(), false) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "movies" }); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + } + + [Fact] + public void IsCaseInsensitive() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "Movies" }, Array.Empty(), false) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "movies" }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public void SkipsDownloadsWithEmptyHash() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "", Category = "movies" }, Array.Empty(), false), + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty(), false) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "movies" }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("hash1", result[0].Hash); + } + } + + public class CreateCategoryAsync_Tests : QBitServiceDCTests + { + public CreateCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task CreatesCategory_WhenMissing() + { + // Arrange + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper + .Setup(x => x.GetCategoriesAsync()) + .ReturnsAsync(new Dictionary()); + + _fixture.ClientWrapper + .Setup(x => x.AddCategoryAsync("new-category")) + .Returns(Task.CompletedTask); + + // Act + await sut.CreateCategoryAsync("new-category"); + + // Assert + _fixture.ClientWrapper.Verify(x => x.AddCategoryAsync("new-category"), Times.Once); + } + + [Fact] + public async Task SkipsCreation_WhenCategoryExists() + { + // Arrange + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper + .Setup(x => x.GetCategoriesAsync()) + .ReturnsAsync(new Dictionary + { + { "existing", new Category { Name = "existing" } } + }); + + // Act + await sut.CreateCategoryAsync("existing"); + + // Assert + _fixture.ClientWrapper.Verify(x => x.AddCategoryAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task IsCaseInsensitive() + { + // Arrange + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper + .Setup(x => x.GetCategoriesAsync()) + .ReturnsAsync(new Dictionary + { + { "existing", new Category { Name = "Existing" } } + }); + + // Act + await sut.CreateCategoryAsync("existing"); + + // Assert + _fixture.ClientWrapper.Verify(x => x.AddCategoryAsync(It.IsAny()), Times.Never); + } + } + + public class DeleteDownload_Tests : QBitServiceDCTests + { + public DeleteDownload_Tests(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task CallsClientDelete() + { + // Arrange + var sut = _fixture.CreateSut(); + const string hash = "test-hash"; + + _fixture.ClientWrapper + .Setup(x => x.DeleteAsync(It.Is>(h => h.Contains(hash)), true)) + .Returns(Task.CompletedTask); + + // Act + await sut.DeleteDownload(hash); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.DeleteAsync(It.Is>(h => h.Contains(hash)), true), + Times.Once); + } + + [Fact] + public async Task DeletesWithData() + { + // Arrange + var sut = _fixture.CreateSut(); + const string hash = "test-hash"; + + _fixture.ClientWrapper + .Setup(x => x.DeleteAsync(It.IsAny>(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await sut.DeleteDownload(hash); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.DeleteAsync(It.IsAny>(), true), + Times.Once); + } + } + + public class ChangeCategoryForNoHardLinksAsync_Tests : QBitServiceDCTests + { + public ChangeCategoryForNoHardLinksAsync_Tests(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task NullDownloads_DoesNothing() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(null); + + // Assert - no exceptions thrown + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EmptyDownloads_DoesNothing() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(new List()); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task MissingHash_SkipsTorrent() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task MissingName_SkipsTorrent() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task MissingCategory_SkipsTorrent() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "", SavePath = "/downloads" }, Array.Empty(), false) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task NoFiles_SkipsTorrent() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync((IReadOnlyList?)null); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task NoHardlinks_ChangesCategory() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.SetTorrentCategoryAsync(It.Is>(h => h.Contains("hash1")), "unlinked"), + Times.Once); + } + + [Fact] + public async Task NoHardlinks_TagMode_AddsTag() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = true, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.AddTorrentTagAsync(It.Is>(h => h.Contains("hash1")), "unlinked"), + Times.Once); + _fixture.ClientWrapper.Verify( + x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HasHardlinks_SkipsTorrent() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(2); // Has hardlinks + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task FileNotFound_SkipsTorrent() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(-1); // Error + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SkippedFiles_IgnoredInCheck() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Skip }, + new TorrentContent { Index = 1, Name = "file2.mkv", Priority = TorrentContentPriority.Normal } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.HardLinkFileService.Verify( + x => x.GetHardLinkCount(It.IsAny(), It.IsAny()), + Times.Once); // Only called for file2.mkv + } + + [Fact] + public async Task WithIgnoredRootDir_PopulatesFileCounts() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked", + UnlinkedIgnoredRootDir = "/ignore" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.HardLinkFileService.Verify( + x => x.PopulateFileCounts("/ignore"), + Times.Once); + } + + [Fact] + public async Task FileWithNullIndex_SkipsTorrent() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = null, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } + }); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PublishesCategoryChangedEvent() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = false, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert - EventPublisher is not mocked, so we just verify the method completed + _fixture.ClientWrapper.Verify( + x => x.SetTorrentCategoryAsync(It.Is>(h => h.Contains("hash1")), "unlinked"), + Times.Once); + } + + [Fact] + public async Task PublishesCategoryChangedEvent_WithTagFlag() + { + // Arrange + var sut = _fixture.CreateSut(); + + var config = new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + UnlinkedUseTag = true, + UnlinkedTargetCategory = "unlinked" + }; + ContextProvider.Set(nameof(DownloadCleanerConfig), config); + + var downloads = new List + { + new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync("hash1")) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert - EventPublisher is not mocked, so we just verify the method completed + _fixture.ClientWrapper.Verify( + x => x.AddTorrentTagAsync(It.Is>(h => h.Contains("hash1")), "unlinked"), + Times.Once); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceFixture.cs new file mode 100644 index 00000000..315baf2c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceFixture.cs @@ -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> Logger { get; } + public MemoryCache Cache { get; } + public Mock FilenameEvaluator { get; } + public Mock Striker { get; } + public Mock DryRunInterceptor { get; } + public Mock HardLinkFileService { get; } + public Mock HttpClientProvider { get; } + public Mock EventPublisher { get; } + public BlocklistProvider BlocklistProvider { get; } + public Mock RuleEvaluator { get; } + public Mock RuleManager { get; } + public Mock ClientWrapper { get; } + + public QBitServiceFixture() + { + Logger = new Mock>(); + Cache = new MemoryCache(new MemoryCacheOptions()); + FilenameEvaluator = new Mock(); + Striker = new Mock(); + DryRunInterceptor = new Mock(); + HardLinkFileService = new Mock(); + HttpClientProvider = new Mock(); + EventPublisher = new Mock(); + BlocklistProvider = TestBlocklistProviderFactory.Create(); + RuleEvaluator = new Mock(); + RuleManager = new Mock(); + ClientWrapper = new Mock(); + + // Setup default behavior for DryRunInterceptor to execute actions directly + DryRunInterceptor + .Setup(x => x.InterceptAsync(It.IsAny(), It.IsAny())) + .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())) + .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(), It.IsAny())) + .Returns((Delegate action, object[] parameters) => + { + return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); + }); + } + + public void Dispose() + { + Cache.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs new file mode 100644 index 00000000..ac5261c8 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs @@ -0,0 +1,1024 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Moq; +using Newtonsoft.Json.Linq; +using QBittorrent.Client; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; + +public class QBitServiceTests : IClassFixture +{ + private readonly QBitServiceFixture _fixture; + + public QBitServiceTests(QBitServiceFixture fixture) + { + _fixture = fixture; + _fixture.ResetMocks(); + } + + public class ShouldRemoveFromArrQueueAsync_BasicScenarios : QBitServiceTests + { + public ShouldRemoveFromArrQueueAsync_BasicScenarios(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task TorrentNotFound_ReturnsEmptyResult() + { + // Arrange + const string hash = "nonexistent"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(Array.Empty()); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.Found); + Assert.False(result.ShouldRemove); + Assert.Equal(DeleteReason.None, result.DeleteReason); + } + + [Fact] + public async Task TorrentIsIgnored_ReturnsEmptyResult_WithFound() + { + // Arrange + const string hash = "test-hash"; + const string ignoredCategory = "ignored-category"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + Category = ignoredCategory, + State = TorrentState.Downloading + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { ignoredCategory }); + + // Assert + Assert.True(result.Found); + Assert.False(result.ShouldRemove); + } + + [Fact] + public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + DownloadSpeed = 1000 + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(true) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.True(result.Found); + Assert.True(result.IsPrivate); + } + + [Fact] + public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + DownloadSpeed = 1000 + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.True(result.Found); + Assert.False(result.IsPrivate); + } + + [Fact] + public async Task TorrentPropertiesNotFound_ReturnsEmptyResult() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync((TorrentProperties?)null); // Properties not found + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.Found); + Assert.False(result.ShouldRemove); + Assert.False(result.IsPrivate); + Assert.Equal(DeleteReason.None, result.DeleteReason); + Assert.False(result.DeleteFromClient); + } + } + + public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : QBitServiceTests + { + public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task AllFilesSkippedByQBit_WithNoDownload_DeletesFromClient() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + CompletionOn = DateTime.UtcNow, + Downloaded = 0 // No data downloaded + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip }, + new TorrentContent { Index = 1, Priority = TorrentContentPriority.Skip } + }); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.AllFilesSkippedByQBit, result.DeleteReason); + Assert.True(result.DeleteFromClient); + } + + [Fact] + public async Task AllFilesSkippedByUser_DeletesFromClient() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + Downloaded = 1000 // Some data downloaded + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip }, + new TorrentContent { Index = 1, Priority = TorrentContentPriority.Skip } + }); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason); + Assert.True(result.DeleteFromClient); + } + + [Fact] + public async Task SomeFilesWanted_DoesNotRemove() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + DownloadSpeed = 1000 + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip }, + new TorrentContent { Index = 1, Priority = TorrentContentPriority.Normal } // At least one wanted + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.ShouldRemove); + } + } + + public class ShouldRemoveFromArrQueueAsync_MetadataDownloadingScenarios : QBitServiceTests + { + public ShouldRemoveFromArrQueueAsync_MetadataDownloadingScenarios(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task DownloadingMetadata_WithStrikesEnabled_IncreasesStrikes() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var queueCleanerConfig = new QueueCleanerConfig + { + Id = Guid.NewGuid(), + DownloadingMetadataMaxStrikes = 3 + }; + + ContextProvider.Set(nameof(QueueCleanerConfig), queueCleanerConfig); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.FetchingMetadata // Metadata downloading state + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.Striker + .Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny(), (ushort)3, StrikeType.DownloadingMetadata)) + .ReturnsAsync(false); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.ShouldRemove); + _fixture.Striker.Verify( + x => x.StrikeAndCheckLimit(hash, It.IsAny(), (ushort)3, StrikeType.DownloadingMetadata), + Times.Once); + } + + [Fact] + public async Task DownloadingMetadata_ExceedsMaxStrikes_RemovesFromQueue() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var queueCleanerConfig = new QueueCleanerConfig + { + Id = Guid.NewGuid(), + DownloadingMetadataMaxStrikes = 3 + }; + + ContextProvider.Set(nameof(QueueCleanerConfig), queueCleanerConfig); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.FetchingMetadata + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.Striker + .Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny(), (ushort)3, StrikeType.DownloadingMetadata)) + .ReturnsAsync(true); // Strike limit exceeded + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.DownloadingMetadata, result.DeleteReason); + Assert.True(result.DeleteFromClient); + } + + [Fact] + public async Task DownloadingMetadata_WithStrikesDisabled_DoesNotRemove() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var queueCleanerConfig = new QueueCleanerConfig + { + Id = Guid.NewGuid(), + DownloadingMetadataMaxStrikes = 0 // Disabled + }; + + ContextProvider.Set(nameof(QueueCleanerConfig), queueCleanerConfig); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.FetchingMetadata + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.ShouldRemove); + _fixture.Striker.Verify( + x => x.StrikeAndCheckLimit(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + } + + public class ShouldRemoveFromArrQueueAsync_SlowDownloadScenarios : QBitServiceTests + { + public ShouldRemoveFromArrQueueAsync_SlowDownloadScenarios(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task SlowDownload_NotInDownloadingState_SkipsCheck() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Uploading, // Not downloading + DownloadSpeed = 100 + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify( + x => x.EvaluateSlowRulesAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SlowDownload_ZeroSpeed_SkipsCheck() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + DownloadSpeed = 0 // Zero speed + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify( + x => x.EvaluateSlowRulesAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SlowDownload_MatchesRule_RemovesFromQueue() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + DownloadSpeed = 1000 // Some speed + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((true, DeleteReason.SlowSpeed, true)); // Rule matched + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason); + Assert.True(result.DeleteFromClient); + } + } + + public class ShouldRemoveFromArrQueueAsync_StalledDownloadScenarios : QBitServiceTests + { + public ShouldRemoveFromArrQueueAsync_StalledDownloadScenarios(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task StalledDownload_NotInStalledState_SkipsCheck() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, // Not stalled + DownloadSpeed = 1000 + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify( + x => x.EvaluateStallRulesAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task StalledDownload_MatchesRule_RemovesFromQueue() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.StalledDownload // Stalled + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((true, DeleteReason.Stalled, true)); // Rule matched + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.Stalled, result.DeleteReason); + Assert.True(result.DeleteFromClient); + } + } + + public class ShouldRemoveFromArrQueueAsync_IntegrationScenarios : QBitServiceTests + { + public ShouldRemoveFromArrQueueAsync_IntegrationScenarios(QBitServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task SlowCheckPasses_ButStalledCheckFails_RemovesFromQueue() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.StalledDownload, // Stalled, not downloading + DownloadSpeed = 0 + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + // Slow check is skipped because not in downloading state + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((true, DeleteReason.Stalled, true)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.Stalled, result.DeleteReason); + _fixture.RuleEvaluator.Verify( + x => x.EvaluateSlowRulesAsync(It.IsAny()), + Times.Never); // Skipped + _fixture.RuleEvaluator.Verify( + x => x.EvaluateStallRulesAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task BothChecksPass_DoesNotRemove() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + DownloadSpeed = 5000000 // Good speed + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) + .ReturnsAsync(new[] { torrentInfo }); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentTrackersAsync(hash)) + .ReturnsAsync(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentPropertiesAsync(hash)) + .ReturnsAsync(properties); + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentContentsAsync(hash)) + .ReturnsAsync(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + Assert.False(result.ShouldRemove); + Assert.Equal(DeleteReason.None, result.DeleteReason); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TestHelpers/TestBlocklistProvider.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TestHelpers/TestBlocklistProvider.cs new file mode 100644 index 00000000..c5252379 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TestHelpers/TestBlocklistProvider.cs @@ -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; + +/// +/// Test implementation of BlocklistProvider for testing purposes +/// +public static class TestBlocklistProviderFactory +{ + public static BlocklistProvider Create() + { + var logger = new Mock>().Object; + var scopeFactory = new Mock().Object; + var cache = new MemoryCache(new MemoryCacheOptions()); + + return new BlocklistProvider(logger, scopeFactory, cache); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemTests.cs deleted file mode 100644 index 6625276e..00000000 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemTests.cs +++ /dev/null @@ -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(() => 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(); - } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs new file mode 100644 index 00000000..5a771dc0 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs @@ -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(() => 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()); + + // 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(); + } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs new file mode 100644 index 00000000..bd2d960f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs @@ -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 +{ + 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(), It.IsAny())) + .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(), It.IsAny())) + .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(), It.IsAny())) + .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(), It.IsAny())) + .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 + { + 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 + { + 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 + { + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" }) + }; + + var categories = new List + { + 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 + { + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" }) + }; + + var categories = new List + { + 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 + { + 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 { "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 + { + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" }) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "movies" }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public void SkipsDownloadsWithEmptyHash() + { + // Arrange + var sut = _fixture.CreateSut(); + + var downloads = new List + { + 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 { "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(ids => ids.Contains(123)), true)) + .Returns(Task.CompletedTask); + + // Act + await sut.DeleteDownload(hash); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.TorrentRemoveAsync(It.Is(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(), It.IsAny()), + 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(), 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(), It.IsAny(), It.IsAny()), 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()); + + // Assert + _fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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 + { + new TransmissionItemWrapper(new TorrentInfo { HashString = "", Name = "Test", DownloadDir = "/downloads" }) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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 + { + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "", DownloadDir = "/downloads" }) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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 + { + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "" }) + }; + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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 + { + 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(), It.IsAny(), It.IsAny()), 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 + { + 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(), It.IsAny(), It.IsAny()), 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 + { + 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(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.TorrentSetLocationAsync(It.Is(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 + { + 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(), It.IsAny())) + .Returns(2); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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 + { + 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(), It.IsAny())) + .Returns(-1); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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 + { + 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(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.HardLinkFileService.Verify( + x => x.GetHardLinkCount(It.IsAny(), It.IsAny()), + 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 + { + 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(), It.IsAny())) + .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 + { + 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(), It.IsAny())) + .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(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 + { + 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(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.TorrentSetLocationAsync(It.Is(ids => ids.Contains(123)), expectedNewLocation, true), + Times.Once); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceFixture.cs new file mode 100644 index 00000000..35e0d9c2 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceFixture.cs @@ -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> Logger { get; } + public MemoryCache Cache { get; } + public Mock FilenameEvaluator { get; } + public Mock Striker { get; } + public Mock DryRunInterceptor { get; } + public Mock HardLinkFileService { get; } + public Mock HttpClientProvider { get; } + public Mock EventPublisher { get; } + public BlocklistProvider BlocklistProvider { get; } + public Mock RuleEvaluator { get; } + public Mock RuleManager { get; } + public Mock ClientWrapper { get; } + + public TransmissionServiceFixture() + { + Logger = new Mock>(); + Cache = new MemoryCache(new MemoryCacheOptions()); + FilenameEvaluator = new Mock(); + Striker = new Mock(); + DryRunInterceptor = new Mock(); + HardLinkFileService = new Mock(); + HttpClientProvider = new Mock(); + EventPublisher = new Mock(); + BlocklistProvider = TestBlocklistProviderFactory.Create(); + RuleEvaluator = new Mock(); + RuleManager = new Mock(); + ClientWrapper = new Mock(); + + DryRunInterceptor + .Setup(x => x.InterceptAsync(It.IsAny(), It.IsAny())) + .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())) + .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(), It.IsAny())) + .Returns((Delegate action, object[] parameters) => + { + return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); + }); + } + + public void Dispose() + { + Cache.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs new file mode 100644 index 00000000..7dcce1dc --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs @@ -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 +{ + 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()); + + 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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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()); + + 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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny()), 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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny()), 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())) + .ReturnsAsync((true, DeleteReason.SlowSpeed, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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())) + .ReturnsAsync((true, DeleteReason.Stalled, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.Stalled, result.DeleteReason); + Assert.True(result.DeleteFromClient); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentItemWrapperTests.cs index 2f5e3c7a..915dd9e4 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentItemWrapperTests.cs @@ -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()); // 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(); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceDCTests.cs new file mode 100644 index 00000000..f0361364 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceDCTests.cs @@ -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 +{ + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }) + }; + + var categories = new List + { + 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 + { + new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "music" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }) + }; + + var categories = new List + { + 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 + { + 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 { "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 + { + new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List { "movies" }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public void SkipsDownloadsWithEmptyHash() + { + // Arrange + var sut = _fixture.CreateSut(); + + var downloads = new List + { + 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 { "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>(h => h.Contains("test-hash")))) + .Returns(Task.CompletedTask); + + // Act + await sut.DeleteDownload(hash); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.RemoveTorrentsAsync(It.Is>(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>())) + .Returns(Task.CompletedTask); + + // Act + await sut.DeleteDownload(hash); + + // Assert + _fixture.ClientWrapper.Verify( + x => x.RemoveTorrentsAsync(It.Is>(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(), It.IsAny()), 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()); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny(), It.IsAny()), 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 + { + 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(), It.IsAny()), 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 + { + 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(), It.IsAny()), 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 + { + 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(), It.IsAny()), 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 + { + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .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 + { + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(2); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny(), It.IsAny()), 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 + { + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(-1); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny(), It.IsAny()), 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 + { + 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 + { + 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(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads); + + // Assert + _fixture.HardLinkFileService.Verify( + x => x.GetHardLinkCount(It.IsAny(), It.IsAny()), + 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 + { + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .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 + { + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .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 + { + 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?)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); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceFixture.cs new file mode 100644 index 00000000..1dceee11 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceFixture.cs @@ -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> Logger { get; } + public MemoryCache Cache { get; } + public Mock FilenameEvaluator { get; } + public Mock Striker { get; } + public Mock DryRunInterceptor { get; } + public Mock HardLinkFileService { get; } + public Mock HttpClientProvider { get; } + public Mock EventPublisher { get; } + public BlocklistProvider BlocklistProvider { get; } + public Mock RuleEvaluator { get; } + public Mock RuleManager { get; } + public Mock ClientWrapper { get; } + + public UTorrentServiceFixture() + { + Logger = new Mock>(); + Cache = new MemoryCache(new MemoryCacheOptions()); + FilenameEvaluator = new Mock(); + Striker = new Mock(); + DryRunInterceptor = new Mock(); + HardLinkFileService = new Mock(); + HttpClientProvider = new Mock(); + EventPublisher = new Mock(); + BlocklistProvider = TestBlocklistProviderFactory.Create(); + RuleEvaluator = new Mock(); + RuleManager = new Mock(); + ClientWrapper = new Mock(); + + DryRunInterceptor + .Setup(x => x.InterceptAsync(It.IsAny(), It.IsAny())) + .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())) + .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(), It.IsAny())) + .Returns((Delegate action, object[] parameters) => + { + return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask); + }); + } + + public void Dispose() + { + Cache.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs new file mode 100644 index 00000000..bb847631 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs @@ -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 +{ + 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()); + + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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 + { + 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()); + + 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 + { + 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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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())) + .ReturnsAsync((false, DeleteReason.None, false)); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny()), 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((false, DeleteReason.None, false)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.False(result.ShouldRemove); + _fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny()), 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) + .ReturnsAsync((true, DeleteReason.SlowSpeed, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + 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 + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.RuleEvaluator + .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) + .ReturnsAsync((true, DeleteReason.Stalled, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + Assert.True(result.ShouldRemove); + Assert.Equal(DeleteReason.Stalled, result.DeleteReason); + Assert.True(result.DeleteFromClient); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/Consumers/DownloadHunterConsumerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/Consumers/DownloadHunterConsumerTests.cs new file mode 100644 index 00000000..d562f83a --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/Consumers/DownloadHunterConsumerTests.cs @@ -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>> _loggerMock; + private readonly Mock _downloadHunterMock; + private readonly DownloadHunterConsumer _consumer; + + public DownloadHunterConsumerTests() + { + _loggerMock = new Mock>>(); + _downloadHunterMock = new Mock(); + _consumer = new DownloadHunterConsumer(_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>())) + .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>())) + .ThrowsAsync(new Exception("Hunt failed")); + + // Act - Should not throw + await _consumer.Consume(contextMock.Object); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("failed to search for replacement")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Consume_PassesCorrectRequestToHunter() + { + // Arrange + var request = CreateHuntRequest(); + var contextMock = CreateConsumeContextMock(request); + DownloadHuntRequest? capturedRequest = null; + + _downloadHunterMock + .Setup(h => h.HuntDownloadsAsync(It.IsAny>())) + .Callback>(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 + { + InstanceType = InstanceType.Lidarr, + Instance = CreateArrInstance(), + SearchItem = new SearchItem { Id = 999 }, + Record = CreateQueueRecord() + }; + var contextMock = CreateConsumeContextMock(request); + + _downloadHunterMock + .Setup(h => h.HuntDownloadsAsync(It.IsAny>())) + .Returns(Task.CompletedTask); + + // Act + await _consumer.Consume(contextMock.Object); + + // Assert + _downloadHunterMock.Verify(h => h.HuntDownloadsAsync( + It.Is>(r => r.InstanceType == InstanceType.Lidarr)), Times.Once); + } + + #endregion + + #region Helper Methods + + private static DownloadHuntRequest CreateHuntRequest() + { + return new DownloadHuntRequest + { + 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>> CreateConsumeContextMock(DownloadHuntRequest message) + { + var mock = new Mock>>(); + mock.Setup(c => c.Message).Returns(message); + return mock; + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/DownloadHunterTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/DownloadHunterTests.cs new file mode 100644 index 00000000..bd275d21 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/DownloadHunterTests.cs @@ -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 _arrClientFactoryMock; + private readonly Mock _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() + .UseSqlite(_connection) + .Options; + + _dataContext = new DataContext(options); + _dataContext.Database.EnsureCreated(); + + _arrClientFactoryMock = new Mock(); + _arrClientMock = new Mock(); + _fakeTimeProvider = new FakeTimeProvider(); + + _arrClientFactoryMock + .Setup(f => f.GetClient(It.IsAny())) + .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()), Times.Never); + _arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny(), It.IsAny>()), 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>(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 CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr) + { + return new DownloadHuntRequest + { + 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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs new file mode 100644 index 00000000..40d2af07 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs @@ -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>> _loggerMock; + private readonly Mock _queueItemRemoverMock; + private readonly DownloadRemoverConsumer _consumer; + + public DownloadRemoverConsumerTests() + { + _loggerMock = new Mock>>(); + _queueItemRemoverMock = new Mock(); + _consumer = new DownloadRemoverConsumer(_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>())) + .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>())) + .ThrowsAsync(new Exception("Remove failed")); + + // Act - Should not throw + await _consumer.Consume(contextMock.Object); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("failed to remove queue item")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Consume_PassesCorrectRequestToRemover() + { + // Arrange + var request = CreateRemoveRequest(); + var contextMock = CreateConsumeContextMock(request); + QueueItemRemoveRequest? capturedRequest = null; + + _queueItemRemoverMock + .Setup(r => r.RemoveQueueItemAsync(It.IsAny>())) + .Callback>(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 + { + 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>())) + .Returns(Task.CompletedTask); + + // Act + await _consumer.Consume(contextMock.Object); + + // Assert + _queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync( + It.Is>(req => + req.RemoveFromClient == true && + req.DeleteReason == DeleteReason.Stalled)), Times.Once); + } + + [Fact] + public async Task Consume_WithDifferentDeleteReasons_HandlesCorrectly() + { + // Arrange + var request = new QueueItemRemoveRequest + { + 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>())) + .Returns(Task.CompletedTask); + + // Act + await _consumer.Consume(contextMock.Object); + + // Assert + _queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync( + It.Is>(req => + req.DeleteReason == DeleteReason.FailedImport)), Times.Once); + } + + [Fact] + public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly() + { + // Arrange + var request = new QueueItemRemoveRequest + { + 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>())) + .Returns(Task.CompletedTask); + + // Act + await _consumer.Consume(contextMock.Object); + + // Assert + _queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync( + It.Is>(req => req.InstanceType == InstanceType.Readarr)), Times.Once); + } + + #endregion + + #region Helper Methods + + private static QueueItemRemoveRequest CreateRemoveRequest() + { + return new QueueItemRemoveRequest + { + 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>> CreateConsumeContextMock(QueueItemRemoveRequest message) + { + var mock = new Mock>>(); + mock.Setup(c => c.Message).Returns(message); + return mock; + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs new file mode 100644 index 00000000..6097829f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs @@ -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> _loggerMock; + private readonly Mock _busMock; + private readonly MemoryCache _memoryCache; + private readonly Mock _arrClientFactoryMock; + private readonly Mock _arrClientMock; + private readonly EventPublisher _eventPublisher; + private readonly EventsContext _eventsContext; + private readonly QueueItemRemover _queueItemRemover; + + public QueueItemRemoverTests() + { + _loggerMock = new Mock>(); + _busMock = new Mock(); + _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + _arrClientFactoryMock = new Mock(); + _arrClientMock = new Mock(); + + _arrClientFactoryMock + .Setup(f => f.GetClient(It.IsAny())) + .Returns(_arrClientMock.Object); + + // Create real EventPublisher with mocked dependencies + var eventsContextOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _eventsContext = new EventsContext(eventsContextOptions); + + var hubContextMock = new Mock>(); + var clientsMock = new Mock(); + clientsMock.Setup(c => c.All).Returns(Mock.Of()); + hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object); + + var dryRunInterceptorMock = new Mock(); + // Setup interceptor to execute the action with params using DynamicInvoke + dryRunInterceptorMock + .Setup(d => d.InterceptAsync(It.IsAny(), It.IsAny())) + .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>(), + Mock.Of(), + 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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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? capturedRequest = null; + + _arrClientMock + .Setup(c => c.DeleteQueueItemAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + _busMock + .Setup(b => b.Publish(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((r, _) => capturedRequest = r) + .Returns(Task.CompletedTask); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + _busMock.Verify(b => b.Publish( + It.IsAny>(), + It.IsAny()), 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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + _busMock.Verify(b => b.Publish( + It.IsAny>(), + It.IsAny()), 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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + _busMock.Verify(b => b.Publish( + It.IsAny>(), + It.IsAny()), 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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound)); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound)); + + // Act & Assert + await Assert.ThrowsAsync( + () => _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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(originalException); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(originalException); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + _arrClientMock.Verify(c => c.DeleteQueueItemAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + 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(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + _arrClientMock.Verify(c => c.DeleteQueueItemAsync( + It.IsAny(), + It.IsAny(), + removeFromClient, + It.IsAny()), Times.Once); + } + + #endregion + + #region Helper Methods + + private static QueueItemRemoveRequest CreateRemoveRequest( + InstanceType instanceType = InstanceType.Sonarr, + bool removeFromClient = true, + DeleteReason deleteReason = DeleteReason.Stalled) + { + return new QueueItemRemoveRequest + { + 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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs new file mode 100644 index 00000000..83201091 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs @@ -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> _logger; + + public DownloadCleanerTests(JobHandlerFixture fixture) + { + _fixture = fixture; + _fixture.RecreateDataContext(); + _fixture.ResetMocks(); + _logger = _fixture.CreateLogger(); + } + + 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 + ); + } + + /// + /// Executes the handler and advances time past the 10-second delay + /// + 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(), + It.Is((v, t) => v.ToString()!.Contains("no download clients")), + It.IsAny(), + It.IsAny>() + ), + 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(), + It.Is((v, t) => v.ToString()!.Contains("no download clients are configured")), + It.IsAny(), + It.IsAny>() + ), + 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())) + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>() + ), + 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())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("No seeding downloads found")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("ignored-hash"); + mockTorrent.Setup(x => x.Name).Returns("Ignored Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).Returns(true); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.GetSeedingDownloads()) + .ReturnsAsync([mockTorrent.Object]); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .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(), + It.Is((v, t) => v.ToString()!.Contains("download is ignored")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("arr-download-hash"); + mockTorrent.Setup(x => x.Name).Returns("Arr Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).Returns(false); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.GetSeedingDownloads()) + .ReturnsAsync([mockTorrent.Object]); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + // Setup arr client to return queue record with matching download ID + var mockArrClient = new Mock(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(It.IsAny())) + .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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, 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(), + It.Is((v, t) => v.ToString()!.Contains("download is used by an arr")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Returns([]); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var mockArrClient = new Mock(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(It.IsAny())) + .Returns(mockArrClient.Object); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, 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(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Returns([mockTorrent.Object]); + mockDownloadService + .Setup(x => x.CreateCategoryAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockDownloadService + .Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny>())) + .Returns(Task.CompletedTask); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await ExecuteWithTimeAdvance(sut); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("hardlinks")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Returns([mockTorrent.Object]); + mockDownloadService + .Setup(x => x.CleanDownloadsAsync( + It.IsAny>(), + It.IsAny>() + )) + .Returns(Task.CompletedTask); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await ExecuteWithTimeAdvance(sut); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("cleanup")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Returns([]); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var mockArrClient = new Mock(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr)) + .Returns(mockArrClient.Object); + + var queueRecords = new List + { + 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(i => i.Id == sonarrInstance.Id), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, 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(i => i.Id == sonarrInstance.Id), + It.IsAny, 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())) + .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(), + It.Is((v, t) => v.ToString()!.Contains("Failed to get seeding downloads")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Throws(new Exception("Filter failed")); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await ExecuteWithTimeAdvance(sut); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to filter downloads for hardlinks evaluation")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Returns([mockTorrent.Object]); + mockDownloadService + .Setup(x => x.CreateCategoryAsync(It.IsAny())) + .ThrowsAsync(new Exception("Create category failed")); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await ExecuteWithTimeAdvance(sut); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to create category")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Returns([mockTorrent.Object]); + mockDownloadService + .Setup(x => x.CreateCategoryAsync(It.IsAny())) + .Returns(Task.CompletedTask); + mockDownloadService + .Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny>())) + .ThrowsAsync(new Exception("Change category failed")); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await ExecuteWithTimeAdvance(sut); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to change category for download client")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task CleanDownloadsAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues() + { + // Arrange + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + TestDataContextFactory.AddCleanCategory(_fixture.DataContext); + + var mockTorrent = new Mock(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Throws(new Exception("Filter failed")); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await ExecuteWithTimeAdvance(sut); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to filter downloads for cleaning")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task CleanDownloadsAsync_WhenCleanDownloadsThrows_LogsErrorAndContinues() + { + // Arrange + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + TestDataContextFactory.AddCleanCategory(_fixture.DataContext); + + var mockTorrent = new Mock(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Returns([mockTorrent.Object]); + mockDownloadService + .Setup(x => x.CleanDownloadsAsync( + It.IsAny>(), + It.IsAny>() + )) + .ThrowsAsync(new Exception("Clean failed")); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await ExecuteWithTimeAdvance(sut); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to clean downloads for download client")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockTorrent.Setup(x => x.Hash).Returns("test-hash"); + mockTorrent.Setup(x => x.Name).Returns("Test Download"); + mockTorrent.Setup(x => x.IsIgnored(It.IsAny>())).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>(), + It.IsAny>() + )) + .Returns([]); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var mockArrClient = new Mock(); + _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(), + It.IsAny(), + It.IsAny, 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(() => task); + Assert.Equal("Arr connection failed", exception.Message); + + // Verify error was logged + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("failed to process")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs new file mode 100644 index 00000000..47eb2b3e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs @@ -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> _logger; + + public MalwareBlockerTests(JobHandlerFixture fixture) + { + _fixture = fixture; + _fixture.RecreateDataContext(); + _fixture.ResetMocks(); + _logger = _fixture.CreateLogger(); + } + + 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(), + It.Is((v, t) => v.ToString()!.Contains("No download clients configured")), + It.IsAny(), + It.IsAny>() + ), + 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(), + It.Is((v, t) => v.ToString()!.Contains("No blocklists are enabled")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task ExecuteInternalAsync_WhenBlocklistEnabled_LoadsBlocklists() + { + // Arrange + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + EnableSonarrBlocklist(); + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + var mockArrClient = new Mock(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(It.IsAny())) + .Returns(mockArrClient.Object); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, 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(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr)) + .Returns(mockArrClient.Object); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, 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(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(It.IsAny())) + .Returns(mockArrClient.Object); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, 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(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("ignored")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task ProcessInstanceAsync_ChecksTorrentClientsForBlockedFiles() + { + // Arrange + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + EnableSonarrBlocklist(); + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.BlockUnwantedFilesAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new BlockFilesResult { Found = true, ShouldRemove = false }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + mockDownloadService.Verify( + x => x.BlockUnwantedFilesAsync("torrent-download-id", It.IsAny>()), + 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(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.BlockUnwantedFilesAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new BlockFilesResult + { + Found = true, + ShouldRemove = true, + IsPrivate = false, + DeleteReason = DeleteReason.AllFilesBlocked + }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _fixture.MessageBus.Verify( + x => x.Publish( + It.Is>(r => + r.DeleteReason == DeleteReason.AllFilesBlocked + ), + It.IsAny() + ), + 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(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.BlockUnwantedFilesAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new BlockFilesResult + { + Found = true, + ShouldRemove = true, + IsPrivate = true, + DeleteReason = DeleteReason.AllFilesBlocked + }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - RemoveFromClient should be false + _fixture.MessageBus.Verify( + x => x.Publish( + It.Is>(r => + r.RemoveFromClient == false + ), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task ProcessInstanceAsync_WhenDownloadNotFoundInTorrentClient_LogsWarning() + { + // Arrange + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + EnableSonarrBlocklist(); + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.BlockUnwantedFilesAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new BlockFilesResult { Found = false }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Download not found in any torrent client")), + It.IsAny(), + It.IsAny>() + ), + 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(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.BlockUnwantedFilesAsync( + It.IsAny(), + It.IsAny>() + )) + .ThrowsAsync(new Exception("Connection failed")); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Error checking download")), + It.IsAny(), + It.IsAny>() + ), + 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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs new file mode 100644 index 00000000..f1905bd6 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs @@ -0,0 +1,1044 @@ +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.Helpers; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Data.Models.Arr; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using QueueCleanerJob = Cleanuparr.Infrastructure.Features.Jobs.QueueCleaner; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs; + +[Collection(JobHandlerCollection.Name)] +public class QueueCleanerTests : IDisposable +{ + private readonly JobHandlerFixture _fixture; + private readonly Mock> _logger; + + public QueueCleanerTests(JobHandlerFixture fixture) + { + _fixture = fixture; + _fixture.RecreateDataContext(); + _fixture.ResetMocks(); + _logger = _fixture.CreateLogger(); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + + private QueueCleanerJob CreateSut() + { + return new QueueCleanerJob( + _logger.Object, + _fixture.DataContext, + _fixture.Cache, + _fixture.MessageBus.Object, + _fixture.ArrClientFactory.Object, + _fixture.ArrQueueIterator.Object, + _fixture.DownloadServiceFactory.Object, + _fixture.EventPublisher.Object + ); + } + + #region ExecuteInternalAsync Tests + + [Fact] + public async Task ExecuteInternalAsync_LoadsStallRulesFromDatabase() + { + // Arrange + TestDataContextFactory.AddStallRule(_fixture.DataContext, enabled: true); + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + var mockArrClient = new Mock(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(It.IsAny())) + .Returns(mockArrClient.Object); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - no debug message about no active stall rules + _logger.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("No active stall rules found")), + It.IsAny(), + It.IsAny>() + ), + Times.Never + ); + } + + [Fact] + public async Task ExecuteInternalAsync_WhenNoStallRules_LogsDebugMessage() + { + // Arrange + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("No active stall rules found")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task ExecuteInternalAsync_LoadsSlowRulesFromDatabase() + { + // Arrange + TestDataContextFactory.AddSlowRule(_fixture.DataContext, enabled: true); + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + var mockArrClient = new Mock(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(It.IsAny())) + .Returns(mockArrClient.Object); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - no debug message about no active slow rules + _logger.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("No active slow rules found")), + It.IsAny(), + It.IsAny>() + ), + Times.Never + ); + } + + [Fact] + public async Task ExecuteInternalAsync_WhenNoSlowRules_LogsDebugMessage() + { + // Arrange + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("No active slow rules found")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task ExecuteInternalAsync_ProcessesAllArrConfigs() + { + // Arrange + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + var mockArrClient = new Mock(); + _fixture.ArrClientFactory + .Setup(x => x.GetClient(It.IsAny())) + .Returns(mockArrClient.Object); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _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.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("download is ignored")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task ProcessInstanceAsync_SkipsAlreadyCachedDownloads() + { + // Arrange + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + // Pre-cache the download using the correct cache key format + var cacheKey = CacheKeys.DownloadMarkedForRemoval("cached-download-id", sonarrInstance.Url); + _fixture.Cache.Set(cacheKey, true); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr)) + .Returns(mockArrClient.Object); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "cached-download-id", + Title = "Cached Download", + Protocol = "torrent" + }; + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("already marked for removal")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task ProcessInstanceAsync_ChecksTorrentClientsForDownloadInfo() + { + // Arrange + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + mockArrClient.Setup(x => x.ShouldRemoveFromQueue( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )).ReturnsAsync(false); + + _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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new DownloadCheckResult { Found = true, ShouldRemove = false }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + mockDownloadService.Verify( + x => x.ShouldRemoveFromArrQueueAsync("torrent-download-id", It.IsAny>()), + Times.Once + ); + } + + [Fact] + public async Task ProcessInstanceAsync_WhenShouldRemove_PublishesRemoveRequest() + { + // Arrange + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr)) + .Returns(mockArrClient.Object); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "stalled-download-id", + Title = "Stalled Download", + Protocol = "torrent", + SeriesId = 1, + EpisodeId = 1 + }; + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new DownloadCheckResult + { + Found = true, + ShouldRemove = true, + IsPrivate = false, + DeleteFromClient = true, + DeleteReason = DeleteReason.Stalled + }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _fixture.MessageBus.Verify( + x => x.Publish( + It.IsAny>(), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task ProcessInstanceAsync_WhenDownloadNotFound_LogsWarning() + { + // Arrange + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + mockArrClient.Setup(x => x.ShouldRemoveFromQueue( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )).ReturnsAsync(false); + + _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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new DownloadCheckResult { Found = false }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Download not found in any torrent client")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + [Fact] + public async Task ProcessInstanceAsync_ChecksFailedImportsWhenDownloadCheckPasses() + { + // Arrange + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + mockArrClient.Setup(x => x.ShouldRemoveFromQueue( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )).ReturnsAsync(false); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr)) + .Returns(mockArrClient.Object); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "download-id", + Title = "Test Download", + Protocol = "torrent" + }; + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new DownloadCheckResult { Found = true, ShouldRemove = false }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - verify failed import check was called + mockArrClient.Verify( + x => x.ShouldRemoveFromQueue( + InstanceType.Sonarr, + queueRecord, + false, + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task ProcessInstanceAsync_WhenFailedImport_PublishesRemoveRequest() + { + // Arrange + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + mockArrClient.Setup(x => x.ShouldRemoveFromQueue( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )).ReturnsAsync(true); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr)) + .Returns(mockArrClient.Object); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "failed-import-id", + Title = "Failed Import", + Protocol = "torrent", + SeriesId = 1, + EpisodeId = 1 + }; + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new DownloadCheckResult { Found = true, ShouldRemove = false }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _fixture.MessageBus.Verify( + x => x.Publish( + It.Is>(r => + r.DeleteReason == DeleteReason.FailedImport + ), + It.IsAny() + ), + Times.Once + ); + } + + #endregion + + #region Error Handling Tests + + [Fact] + public async Task ProcessInstanceAsync_WhenDownloadServiceThrows_LogsErrorAndContinues() + { + // Arrange + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + mockArrClient.Setup(x => x.ShouldRemoveFromQueue( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + )).ReturnsAsync(false); + + _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(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ThrowsAsync(new Exception("Connection failed")); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Error checking download")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + } + + #endregion + + #region GenericHandler PublishQueueItemRemoveRequest Tests + + [Fact] + public async Task PublishQueueItemRemoveRequest_WhenCacheHasKey_SkipsRemovalRequest() + { + // Arrange - test the cache skip in GenericHandler.PublishQueueItemRemoveRequest + // This simulates a race condition where the key is added between QueueCleaner's check + // and calling PublishQueueItemRemoveRequest + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr)) + .Returns(mockArrClient.Object); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "race-condition-download", + Title = "Race Condition Download", + Protocol = "torrent", + MovieId = 1 + }; + + // Simulate race condition: add to cache when ShouldRemoveFromArrQueueAsync is called + // (after QueueCleaner's cache check but before PublishQueueItemRemoveRequest) + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(() => + { + // Add to cache here - simulating another thread/process adding this + var cacheKey = CacheKeys.DownloadMarkedForRemoval(queueRecord.DownloadId, radarrInstance.Url); + _fixture.Cache.Set(cacheKey, true); + + return new DownloadCheckResult + { + Found = true, + ShouldRemove = true, + IsPrivate = false, + DeleteFromClient = true, + DeleteReason = DeleteReason.Stalled + }; + }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - should log "skip removal request | already marked for removal" from GenericHandler + _logger.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("skip removal request") && v.ToString()!.Contains("already marked for removal")), + It.IsAny(), + It.IsAny>() + ), + Times.Once + ); + + // Verify no publish was made + _fixture.MessageBus.Verify( + x => x.Publish( + It.IsAny>(), + It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public async Task PublishQueueItemRemoveRequest_ForRadarr_PublishesSearchItemRequest() + { + // Arrange - test the SearchItem branch for Radarr (not SeriesSearchItem) + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr)) + .Returns(mockArrClient.Object); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "radarr-download-id", + Title = "Radarr Download", + Protocol = "torrent", + MovieId = 42 + }; + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new DownloadCheckResult + { + Found = true, + ShouldRemove = true, + IsPrivate = false, + DeleteFromClient = true, + DeleteReason = DeleteReason.Stalled + }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - should publish QueueItemRemoveRequest (not SeriesSearchItem) + _fixture.MessageBus.Verify( + x => x.Publish( + It.Is>(r => + r.InstanceType == InstanceType.Radarr && + r.SearchItem.Id == 42 && + r.DeleteReason == DeleteReason.Stalled + ), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task PublishQueueItemRemoveRequest_ForLidarr_PublishesSearchItemRequest() + { + // Arrange - test the SearchItem branch for Lidarr + var lidarrInstance = TestDataContextFactory.AddLidarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Lidarr)) + .Returns(mockArrClient.Object); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "lidarr-download-id", + Title = "Lidarr Download", + Protocol = "torrent", + AlbumId = 123 + }; + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new DownloadCheckResult + { + Found = true, + ShouldRemove = true, + IsPrivate = false, + DeleteFromClient = true, + DeleteReason = DeleteReason.SlowSpeed + }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - should publish QueueItemRemoveRequest with AlbumId + _fixture.MessageBus.Verify( + x => x.Publish( + It.Is>(r => + r.InstanceType == InstanceType.Lidarr && + r.SearchItem.Id == 123 && + r.DeleteReason == DeleteReason.SlowSpeed + ), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task PublishQueueItemRemoveRequest_ForReadarr_PublishesSearchItemRequest() + { + // Arrange - test the SearchItem branch for Readarr + var readarrInstance = TestDataContextFactory.AddReadarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = new Mock(); + mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Readarr)) + .Returns(mockArrClient.Object); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "readarr-download-id", + Title = "Readarr Download", + Protocol = "torrent", + BookId = 456 + }; + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate( + It.IsAny(), + It.IsAny(), + It.IsAny, Task>>() + )) + .Returns(async (IArrClient client, ArrInstance instance, Func, Task> callback) => + { + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .Setup(x => x.ShouldRemoveFromArrQueueAsync( + It.IsAny(), + It.IsAny>() + )) + .ReturnsAsync(new DownloadCheckResult + { + Found = true, + ShouldRemove = true, + IsPrivate = false, + DeleteFromClient = true, + DeleteReason = DeleteReason.Stalled + }); + + _fixture.DownloadServiceFactory + .Setup(x => x.GetDownloadService(It.IsAny())) + .Returns(mockDownloadService.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - should publish QueueItemRemoveRequest with BookId + _fixture.MessageBus.Verify( + x => x.Publish( + It.Is>(r => + r.InstanceType == InstanceType.Readarr && + r.SearchItem.Id == 456 && + r.DeleteReason == DeleteReason.Stalled + ), + It.IsAny() + ), + Times.Once + ); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerCollection.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerCollection.cs new file mode 100644 index 00000000..ad86a3fd --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerCollection.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; + +/// +/// Collection definition for job handler tests that share . +/// Tests in this collection run sequentially to avoid FakeTimeProvider interference. +/// +[CollectionDefinition(Name)] +public class JobHandlerCollection : ICollectionFixture +{ + public const string Name = "JobHandler"; +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs new file mode 100644 index 00000000..4bb9b523 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs @@ -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; + +/// +/// Base fixture for job handler tests providing common mock dependencies +/// +public class JobHandlerFixture : IDisposable +{ + public DataContext DataContext { get; private set; } + public MemoryCache Cache { get; } + public Mock MessageBus { get; } + public Mock ArrClientFactory { get; } + public Mock ArrQueueIterator { get; } + public Mock DownloadServiceFactory { get; } + public Mock EventPublisher { get; } + public Mock BlocklistProvider { get; } + public FakeTimeProvider TimeProvider { get; private set; } + + public JobHandlerFixture() + { + DataContext = TestDataContextFactory.Create(); + Cache = new MemoryCache(new MemoryCacheOptions()); + MessageBus = new Mock(); + ArrClientFactory = new Mock(); + ArrQueueIterator = new Mock(); + DownloadServiceFactory = new Mock(); + EventPublisher = new Mock(); + BlocklistProvider = new Mock(); + TimeProvider = new FakeTimeProvider(); + + // Setup default behaviors + SetupDefaultBehaviors(); + } + + private void SetupDefaultBehaviors() + { + // EventPublisher methods return completed task by default + EventPublisher + .Setup(x => x.PublishAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + } + + /// + /// Creates a mock logger for a specific handler type + /// + public Mock> CreateLogger() where T : GenericHandler + { + return new Mock>(); + } + + /// + /// Creates a mock download service + /// + public Mock CreateMockDownloadService(string clientName = "Test Client") + { + var mock = new Mock(); + 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; + } + + /// + /// Sets up the DownloadServiceFactory to return the specified mock services + /// + public void SetupDownloadServices(params Mock[] services) + { + foreach (var service in services) + { + DownloadServiceFactory + .Setup(x => x.GetDownloadService(service.Object.ClientConfig)) + .Returns(service.Object); + } + } + + /// + /// Creates a fresh DataContext, disposing the old one + /// + 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); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs new file mode 100644 index 00000000..a113bf17 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs @@ -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; + +/// +/// Factory for creating SQLite in-memory DataContext instances for testing +/// +public static class TestDataContextFactory +{ + /// + /// Creates a new SQLite in-memory DataContext with default seed data + /// + public static DataContext Create(bool seedData = true) + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var context = new DataContext(options); + context.Database.EnsureCreated(); + + if (seedData) + { + SeedDefaultData(context); + } + + return context; + } + + /// + /// Seeds the minimum required data for GenericHandler.ExecuteAsync() to work + /// + 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(); + } + + /// + /// Adds an enabled Sonarr instance to the context + /// + 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; + } + + /// + /// Adds an enabled Radarr instance to the context + /// + 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; + } + + /// + /// Adds an enabled Lidarr instance to the context + /// + 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; + } + + /// + /// Adds an enabled Readarr instance to the context + /// + 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; + } + + /// + /// Adds an enabled Whisparr instance to the context + /// + 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; + } + + /// + /// Adds an enabled download client to the context + /// + 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; + } + + /// + /// Adds a stall rule to the context + /// + 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; + } + + /// + /// Adds a slow rule to the context + /// + 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; + } + + /// + /// Adds a clean category to the download cleaner config + /// + 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; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/MalwareBlocker/BlocklistProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/MalwareBlocker/BlocklistProviderTests.cs new file mode 100644 index 00000000..08c2530d --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/MalwareBlocker/BlocklistProviderTests.cs @@ -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 _logger; + private readonly IServiceScopeFactory _scopeFactory; + private readonly BlocklistProvider _provider; + + public BlocklistProviderTests() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + _logger = Substitute.For>(); + _scopeFactory = Substitute.For(); + + _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 { "*.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 + { + 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 { "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 { $"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 { "sonarr_pattern" }; + var radarrPatterns = new ConcurrentBag { "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"); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseProxyTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseProxyTests.cs new file mode 100644 index 00000000..61665ea1 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Apprise/AppriseProxyTests.cs @@ -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 _httpClientFactoryMock; + private readonly Mock _httpMessageHandlerMock; + + public AppriseProxyTests() + { + _httpMessageHandlerMock = new Mock(); + _httpClientFactoryMock = new Mock(); + + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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(() => + 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(() => + 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(() => + 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(() => + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Network error")); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload(), CreateConfig())); + Assert.Contains("Unable to send notification", ex.Message); + } + + #endregion + + #region Helper Methods + + private void SetupSuccessResponse() + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + } + + private void SetupErrorResponse(HttpStatusCode statusCode) + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Error", null, statusCode)); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/AppriseProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/AppriseProviderTests.cs new file mode 100644 index 00000000..987665d8 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/AppriseProviderTests.cs @@ -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 _proxyMock; + private readonly AppriseConfig _config; + private readonly AppriseProvider _provider; + + public AppriseProviderTests() + { + _proxyMock = new Mock(); + _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(), _config)) + .Callback((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(), _config)) + .Callback((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() + }; + + ApprisePayload? capturedPayload = null; + + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + .Callback((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(), _config)) + .Callback((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(), _config)) + .ThrowsAsync(new Exception("Proxy error")); + + // Act & Assert + await Assert.ThrowsAsync(() => _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() + }; + + ApprisePayload? capturedPayload = null; + + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + .Callback((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() + }; + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Notifiarr/NotifiarrProxyTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Notifiarr/NotifiarrProxyTests.cs new file mode 100644 index 00000000..09018d17 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Notifiarr/NotifiarrProxyTests.cs @@ -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> _loggerMock; + private readonly Mock _httpClientFactoryMock; + private readonly Mock _httpMessageHandlerMock; + + public NotifiarrProxyTests() + { + _loggerMock = new Mock>(); + _httpMessageHandlerMock = new Mock(); + _httpClientFactoryMock = new Mock(); + + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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(), + It.Is((v, t) => v.ToString()!.Contains("sending notification")), + It.IsAny(), + It.IsAny>()), + 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(() => + 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(() => + 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(() => + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Network error")); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload(), CreateConfig())); + Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Helper Methods + + private void SetupSuccessResponse() + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + } + + private void SetupErrorResponse(HttpStatusCode statusCode) + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Error", null, statusCode)); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotifiarrProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotifiarrProviderTests.cs new file mode 100644 index 00000000..bc2d1a99 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotifiarrProviderTests.cs @@ -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 _proxyMock; + private readonly NotifiarrConfig _config; + private readonly NotifiarrProvider _provider; + + public NotifiarrProviderTests() + { + _proxyMock = new Mock(); + _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(), _config)) + .Callback((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(), _config)) + .Callback((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(), _config)) + .Callback((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() + }; + + NotifiarrPayload? capturedPayload = null; + + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + .Callback((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(), _config)) + .Callback((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(), _config)) + .Callback((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(), _config)) + .Callback((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(), _config)) + .ThrowsAsync(new Exception("Proxy error")); + + // Act & Assert + await Assert.ThrowsAsync(() => _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() + }; + + NotifiarrPayload? capturedPayload = null; + + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + .Callback((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() + }; + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs new file mode 100644 index 00000000..244f3a04 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs @@ -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> _loggerMock; + private readonly NotificationConfigurationService _service; + + public NotificationConfigurationServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new DataContext(options); + _loggerMock = new Mock>(); + _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().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().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().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().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().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().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().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().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().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().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().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().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(), + It.Is((v, t) => v.ToString()!.Contains("cache invalidated")), + It.IsAny(), + It.IsAny>()), + 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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConsumerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConsumerTests.cs new file mode 100644 index 00000000..bf0cc421 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConsumerTests.cs @@ -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> _serviceLoggerMock; + private readonly Mock _configurationServiceMock; + private readonly Mock _providerFactoryMock; + private readonly NotificationService _notificationService; + private readonly FakeTimeProvider _timeProvider; + + public NotificationConsumerTests() + { + _serviceLoggerMock = new Mock>(); + _configurationServiceMock = new Mock(); + _providerFactoryMock = new Mock(); + _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(); + 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(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .Callback(e => capturedEventType = e) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())).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(); + 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(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .Callback(e => capturedEventType = e) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())).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(); + 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(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .Callback(e => capturedEventType = e) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())).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(); + 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(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .Callback(e => capturedEventType = e) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())).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(); + 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(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .Callback(e => capturedEventType = e) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())).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(); + 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(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .Callback(e => capturedEventType = e) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())).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(); + var notification = new CategoryChangedNotification + { + Title = "Category Changed", + Description = "Category updated", + Level = NotificationLevel.Information + }; + var contextMock = CreateConsumeContextMock(notification); + NotificationEventType? capturedEventType = null; + + var providerMock = new Mock(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .Callback(e => capturedEventType = e) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())).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(); + 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(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock + .Setup(p => p.SendNotificationAsync(It.IsAny())) + .Callback(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(); + 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(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock + .Setup(p => p.SendNotificationAsync(It.IsAny())) + .Callback(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(); + 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 + { + new() { Key = "CustomKey1", Value = "CustomValue1" }, + new() { Key = "CustomKey2", Value = "CustomValue2" } + } + }; + var contextMock = CreateConsumeContextMock(notification); + NotificationContext? capturedContext = null; + + var providerMock = new Mock(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock + .Setup(p => p.SendNotificationAsync(It.IsAny())) + .Callback(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(); + var notification = new DownloadCleanedNotification + { + Title = "Download Cleaned", + Description = "Test", + Level = NotificationLevel.Information + }; + var contextMock = CreateConsumeContextMock(notification); + NotificationContext? capturedContext = null; + + var providerMock = new Mock(); + var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; + + _configurationServiceMock + .Setup(s => s.GetProvidersForEventAsync(It.IsAny())) + .ReturnsAsync(new List { providerDto }); + + _providerFactoryMock + .Setup(f => f.CreateProvider(It.IsAny())) + .Returns(providerMock.Object); + + providerMock + .Setup(p => p.SendNotificationAsync(It.IsAny())) + .Callback(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(); + 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())) + .ReturnsAsync(new List()); + + // Act + await ConsumeWithTimeAdvance(consumer, contextMock); + + // Assert + _providerFactoryMock.Verify(f => f.CreateProvider(It.IsAny()), Times.Never); + } + + #endregion + + #region Helper Methods + + private NotificationConsumer CreateConsumer() where T : Notification + { + var loggerMock = new Mock>>(); + return new NotificationConsumer(loggerMock.Object, _notificationService, _timeProvider); + } + + private static Mock> CreateConsumeContextMock(T message) where T : class + { + var mock = new Mock>(); + mock.Setup(c => c.Message).Returns(message); + return mock; + } + + /// + /// Executes the consumer and advances time past the 1-second spam prevention delay + /// + private async Task ConsumeWithTimeAdvance(NotificationConsumer consumer, Mock> contextMock) where T : Notification + { + var task = consumer.Consume(contextMock.Object); + _timeProvider.Advance(TimeSpan.FromSeconds(1)); + await task; + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs new file mode 100644 index 00000000..5ffa6c04 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs @@ -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 _appriseProxyMock; + private readonly Mock _ntfyProxyMock; + private readonly Mock _notifiarrProxyMock; + private readonly IServiceProvider _serviceProvider; + private readonly NotificationProviderFactory _factory; + + public NotificationProviderFactoryTests() + { + _appriseProxyMock = new Mock(); + _ntfyProxyMock = new Mock(); + _notifiarrProxyMock = new Mock(); + + 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(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 { "test-topic" }, + AuthenticationType = NtfyAuthenticationType.None, + Priority = NtfyPriority.Default + } + }; + + // Act + var provider = _factory.CreateProvider(config); + + // Assert + Assert.NotNull(provider); + Assert.IsType(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(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(() => _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 { "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 { "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(() => factoryWithNoServices.CreateProvider(config)); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs new file mode 100644 index 00000000..1454a8b2 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs @@ -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> _loggerMock; + private readonly Mock _dryRunInterceptorMock; + private readonly Mock _configServiceMock; + private readonly Mock _providerFactoryMock; + private readonly NotificationPublisher _publisher; + + public NotificationPublisherTests() + { + _loggerMock = new Mock>(); + _dryRunInterceptorMock = new Mock(); + _configServiceMock = new Mock(); + _providerFactoryMock = new Mock(); + + // Setup dry run interceptor to call through + _dryRunInterceptorMock + .Setup(d => d.InterceptAsync(It.IsAny(), It.IsAny())) + .Returns(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(rule); + + var providerDto = CreateProviderDto(); + var providerMock = new Mock(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.StalledStrike)) + .ReturnsAsync(new List { 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( + 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(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike)) + .ReturnsAsync(new List { 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( + 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(rule); + } + + var providerDto = CreateProviderDto(); + var providerMock = new Mock(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(expectedEventType)) + .ReturnsAsync(new List { 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())) + .ReturnsAsync(new List()); + + // 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(); + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())) + .ThrowsAsync(new Exception("Provider failed")); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny())) + .ReturnsAsync(new List { 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(), + It.Is((v, t) => v.ToString()!.Contains("Failed to send notification")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region NotifyQueueItemDeleted Tests + + [Fact] + public async Task NotifyQueueItemDeleted_SendsNotificationWithCorrectContext() + { + // Arrange + SetupContext(); + + var providerDto = CreateProviderDto(); + var providerMock = new Mock(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted)) + .ReturnsAsync(new List { 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( + 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(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted)) + .ReturnsAsync(new List { 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( + 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(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned)) + .ReturnsAsync(new List { 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( + 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(); + NotificationContext? capturedContext = null; + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned)) + .ReturnsAsync(new List { providerDto }); + _providerFactoryMock.Setup(f => f.CreateProvider(providerDto)) + .Returns(providerMock.Object); + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())) + .Callback(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(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged)) + .ReturnsAsync(new List { 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( + 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(); + NotificationContext? capturedContext = null; + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged)) + .ReturnsAsync(new List { providerDto }); + _providerFactoryMock.Setup(f => f.CreateProvider(providerDto)) + .Returns(providerMock.Object); + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())) + .Callback(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(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged)) + .ReturnsAsync(new List { 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( + 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(); + var providerMock2 = new Mock(); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike)) + .ReturnsAsync(new List { 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()), Times.Once); + providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SendNotificationAsync_WhenOneProviderFails_OthersStillSend() + { + // Arrange + SetupContext(); + + var providerDto1 = CreateProviderDto("Provider1"); + var providerDto2 = CreateProviderDto("Provider2"); + var providerMock1 = new Mock(); + var providerMock2 = new Mock(); + + providerMock1.Setup(p => p.SendNotificationAsync(It.IsAny())) + .ThrowsAsync(new Exception("Failed")); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike)) + .ReturnsAsync(new List { 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()), Times.Once); + } + + [Fact] + public async Task SendNotificationAsync_UsesDryRunInterceptor() + { + // Arrange + SetupContext(); + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny())) + .ReturnsAsync(new List()); + + // Act + await _publisher.NotifyStrike(StrikeType.FailedImport, 1); + + // Assert + _dryRunInterceptorMock.Verify(d => d.InterceptAsync( + It.IsAny>(), + 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(), It.IsAny())) + .ThrowsAsync(new Exception("Interceptor failed")); + + SetupContext(); + + // Act + await _publisher.NotifyStrike(StrikeType.FailedImport, 1); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("failed to notify strike")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task NotifyQueueItemDeleted_WhenExceptionOccurs_LogsError() + { + // Arrange + _dryRunInterceptorMock + .Setup(d => d.InterceptAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Error")); + + SetupContext(); + + // Act + await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to notify queue item deleted")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task NotifyDownloadCleaned_WhenExceptionOccurs_LogsError() + { + // Arrange + _dryRunInterceptorMock + .Setup(d => d.InterceptAsync(It.IsAny(), It.IsAny())) + .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(), + It.Is((v, t) => v.ToString()!.Contains("Failed to notify download cleaned")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task NotifyCategoryChanged_WhenExceptionOccurs_LogsError() + { + // Arrange + _dryRunInterceptorMock + .Setup(d => d.InterceptAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Error")); + + SetupDownloadCleanerContext(); + + // Act + await _publisher.NotifyCategoryChanged("old", "new", false); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to notify category changed")), + It.IsAny(), + It.IsAny>()), + 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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationServiceTests.cs new file mode 100644 index 00000000..0b7fb47f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationServiceTests.cs @@ -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> _loggerMock; + private readonly Mock _configServiceMock; + private readonly Mock _providerFactoryMock; + private readonly NotificationService _service; + + public NotificationServiceTests() + { + _loggerMock = new Mock>(); + _configServiceMock = new Mock(); + _providerFactoryMock = new Mock(); + + _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()); + + // Act + await _service.SendNotificationAsync(eventType, context); + + // Assert + _providerFactoryMock.Verify(f => f.CreateProvider(It.IsAny()), 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(); + providerMock.SetupGet(p => p.Name).Returns("TestProvider"); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType)) + .ReturnsAsync(new List { 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(); + provider1Mock.SetupGet(p => p.Name).Returns("Provider1"); + + var provider2Mock = new Mock(); + provider2Mock.SetupGet(p => p.Name).Returns("Provider2"); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType)) + .ReturnsAsync(new List { 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(); + failingProviderMock.SetupGet(p => p.Name).Returns("FailingProvider"); + failingProviderMock.Setup(p => p.SendNotificationAsync(It.IsAny())) + .ThrowsAsync(new Exception("Provider failed")); + + var successProviderMock = new Mock(); + successProviderMock.SetupGet(p => p.Name).Returns("SuccessProvider"); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType)) + .ReturnsAsync(new List { 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(); + providerMock.SetupGet(p => p.Name).Returns("FailingProvider"); + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())) + .ThrowsAsync(new Exception("Provider failed")); + + _configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType)) + .ReturnsAsync(new List { 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(), + It.Is((v, t) => v.ToString()!.Contains("Failed to send notification")), + It.IsAny(), + It.IsAny>()), + 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(), + It.Is((v, t) => v.ToString()!.Contains("Failed to send notifications")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region SendTestNotificationAsync Tests + + [Fact] + public async Task SendTestNotificationAsync_SendsTestContext() + { + // Arrange + var providerConfig = CreateProviderConfig("TestProvider"); + var providerMock = new Mock(); + 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(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(); + 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(), + It.Is((v, t) => v.ToString()!.Contains("Test notification sent successfully")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task SendTestNotificationAsync_ProviderFails_ThrowsException() + { + // Arrange + var providerConfig = CreateProviderConfig("FailingProvider"); + var providerMock = new Mock(); + providerMock.SetupGet(p => p.Name).Returns("FailingProvider"); + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())) + .ThrowsAsync(new Exception("Test notification failed")); + + _providerFactoryMock.Setup(f => f.CreateProvider(providerConfig)) + .Returns(providerMock.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => _service.SendTestNotificationAsync(providerConfig)); + } + + [Fact] + public async Task SendTestNotificationAsync_ProviderFails_LogsError() + { + // Arrange + var providerConfig = CreateProviderConfig("FailingProvider"); + var providerMock = new Mock(); + providerMock.SetupGet(p => p.Name).Returns("FailingProvider"); + providerMock.Setup(p => p.SendNotificationAsync(It.IsAny())) + .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(), + It.Is((v, t) => v.ToString()!.Contains("Failed to send test notification")), + It.IsAny(), + It.IsAny>()), + 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(); + 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(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 + { + ["Key1"] = "Value1", + ["Key2"] = "Value2" + } + }; + } + + private static NotificationProviderDto CreateProviderConfig(string name) + { + return new NotificationProviderDto + { + Id = Guid.NewGuid(), + Name = name, + Type = NotificationProviderType.Apprise, + IsEnabled = true + }; + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Ntfy/NtfyProxyTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Ntfy/NtfyProxyTests.cs new file mode 100644 index 00000000..0a721b16 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Ntfy/NtfyProxyTests.cs @@ -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 _httpClientFactoryMock; + private readonly Mock _httpMessageHandlerMock; + + public NtfyProxyTests() + { + _httpMessageHandlerMock = new Mock(); + _httpClientFactoryMock = new Mock(); + + 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 { "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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Network error")); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload(), CreateConfig())); + Assert.Contains("Unable to send notification", ex.Message); + } + + #endregion + + #region Helper Methods + + private void SetupSuccessResponse() + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + } + + private void SetupErrorResponse(HttpStatusCode statusCode) + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Error", null, statusCode)); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NtfyProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NtfyProviderTests.cs new file mode 100644 index 00000000..7768725a --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NtfyProviderTests.cs @@ -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 _proxyMock; + private readonly NtfyConfig _config; + private readonly NtfyProvider _provider; + + public NtfyProviderTests() + { + _proxyMock = new Mock(); + _config = new NtfyConfig + { + Id = Guid.NewGuid(), + ServerUrl = "http://ntfy.example.com", + Topics = new List { "test-topic" }, + AuthenticationType = NtfyAuthenticationType.None, + Priority = NtfyPriority.Default, + Tags = new List { "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(), _config)) + .Callback((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 { "topic1", "topic2", "topic3" }, + AuthenticationType = NtfyAuthenticationType.None, + Priority = NtfyPriority.Default, + Tags = new List() + }; + + var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object); + var context = CreateTestContext(); + + var capturedPayloads = new List(); + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), config)) + .Callback((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(), _config)) + .Callback((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 { "test" }, + AuthenticationType = NtfyAuthenticationType.None, + Priority = NtfyPriority.High, + Tags = new List() + }; + + var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object); + var context = CreateTestContext(); + + NtfyPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), config)) + .Callback((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(), _config)) + .Callback((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 { " topic-with-spaces " }, + AuthenticationType = NtfyAuthenticationType.None, + Priority = NtfyPriority.Default, + Tags = new List() + }; + + var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object); + var context = CreateTestContext(); + + NtfyPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), config)) + .Callback((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 { "valid-topic", "", " ", "another-valid" }, + AuthenticationType = NtfyAuthenticationType.None, + Priority = NtfyPriority.Default, + Tags = new List() + }; + + var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object); + var context = CreateTestContext(); + + var capturedPayloads = new List(); + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), config)) + .Callback((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(), _config)) + .ThrowsAsync(new Exception("Proxy error")); + + // Act & Assert + await Assert.ThrowsAsync(() => _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() + }; + + NtfyPayload? capturedPayload = null; + + _proxyMock.Setup(p => p.SendNotification(It.IsAny(), _config)) + .Callback((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() + }; + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/QueueCleaner/QueueRuleMatchTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/QueueCleaner/QueueRuleMatchTests.cs index 8b02264e..00e2b66c 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/QueueCleaner/QueueRuleMatchTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/QueueCleaner/QueueRuleMatchTests.cs @@ -399,13 +399,12 @@ public class QueueRuleMatchTests Assert.False(rule.MatchesTorrent(publicTorrent.Object)); } - private static Mock CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB") + private static Mock CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB") { - var torrent = new Mock(); + var torrent = new Mock(); 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()); return torrent; } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Health/ApplicationHealthCheckTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Health/ApplicationHealthCheckTests.cs new file mode 100644 index 00000000..db387241 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Health/ApplicationHealthCheckTests.cs @@ -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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Health/DatabaseHealthCheckTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Health/DatabaseHealthCheckTests.cs new file mode 100644 index 00000000..68ea6a67 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Health/DatabaseHealthCheckTests.cs @@ -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; + +/// +/// Basic tests for DatabaseHealthCheck. +/// Note: Full integration testing requires a real database since in-memory provider +/// doesn't support migrations (GetPendingMigrationsAsync). +/// +public class DatabaseHealthCheckTests : IDisposable +{ + private readonly Mock> _loggerMock; + private DataContext? _dataContext; + + public DatabaseHealthCheckTests() + { + _loggerMock = new Mock>(); + } + + public void Dispose() + { + _dataContext?.Dispose(); + } + + #region Constructor Tests + + [Fact] + public void Constructor_WithValidDependencies_CreatesInstance() + { + // Arrange + var options = new DbContextOptionsBuilder() + .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() + .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() + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } + + [Fact] + public async Task CheckHealthAsync_WhenUnhealthy_DescriptionIndicatesFailure() + { + // Arrange + var options = new DbContextOptionsBuilder() + .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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Health/DownloadClientsHealthCheckTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Health/DownloadClientsHealthCheckTests.cs new file mode 100644 index 00000000..bab80f82 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Health/DownloadClientsHealthCheckTests.cs @@ -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 _healthCheckServiceMock; + private readonly Mock> _loggerMock; + private readonly DownloadClientsHealthCheck _healthCheck; + + public DownloadClientsHealthCheckTests() + { + _healthCheckServiceMock = new Mock(); + _loggerMock = new Mock>(); + _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()); + + // 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.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.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.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.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.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.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.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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckBackgroundServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckBackgroundServiceTests.cs new file mode 100644 index 00000000..4d3d86e9 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckBackgroundServiceTests.cs @@ -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> _loggerMock; + private readonly Mock _healthCheckServiceMock; + private HealthCheckBackgroundService? _service; + + public HealthCheckBackgroundServiceTests() + { + _loggerMock = new Mock>(); + _healthCheckServiceMock = new Mock(); + } + + 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.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.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(), + It.Is((v, t) => v.ToString()!.Contains("healthy")), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } + + [Fact] + public async Task ExecuteAsync_WhenSomeClientsUnhealthy_LogsWarningMessage() + { + // Arrange + var service = CreateService(); + var healthResults = new Dictionary + { + { 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(), + It.Is((v, t) => v.ToString()!.Contains("unhealthy")), + It.IsAny(), + It.IsAny>()), + 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.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(), + It.Is((v, t) => v.ToString()!.Contains("Error performing periodic health check")), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } + + [Fact] + public async Task ExecuteAsync_WithNoClients_HandlesEmptyResults() + { + // Arrange + var service = CreateService(); + var healthResults = new Dictionary(); + + _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 + { + { 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(), + It.Is((v, t) => v.ToString()!.Contains("UnhealthyClient") || + v.ToString()!.Contains("Connection timeout")), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } + + #endregion + + #region Lifecycle Tests + + [Fact] + public async Task StartAsync_StartsBackgroundService() + { + // Arrange + _healthCheckServiceMock + .Setup(s => s.CheckAllClientsHealthAsync()) + .ReturnsAsync(new Dictionary()); + + 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()); + + 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(), + It.Is((v, t) => v.ToString()!.Contains("stopped")), + It.IsAny(), + It.IsAny>()), + 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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckServiceFixture.cs deleted file mode 100644 index 068985e1..00000000 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckServiceFixture.cs +++ /dev/null @@ -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 Logger { get; } -// public IConfigManager ConfigManager { get; } -// public IDownloadClientFactory ClientFactory { get; } -// public IDownloadService MockClient { get; } -// public DownloadClientConfigs DownloadClientConfigs { get; } -// -// public HealthCheckServiceFixture() -// { -// Logger = Substitute.For>(); -// ConfigManager = Substitute.For(); -// ClientFactory = Substitute.For(); -// MockClient = Substitute.For(); -// Guid clientId = Guid.NewGuid(); -// -// // Set up test download client config -// DownloadClientConfigs = new DownloadClientConfigs -// { -// Clients = new List -// { -// 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()).Returns(MockClient); -// MockClient.GetClientId().Returns(clientId); -// -// // Set up mock config manager -// ConfigManager.GetConfiguration().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 -// } -// } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckServiceTests.cs deleted file mode 100644 index 1de568e4..00000000 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Health/HealthCheckServiceTests.cs +++ /dev/null @@ -1,177 +0,0 @@ -// using Infrastructure.Health; -// using NSubstitute; -// using Shouldly; -// -// namespace Infrastructure.Tests.Health; -// -// public class HealthCheckServiceTests : IClassFixture -// { -// 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().Returns( -// Task.FromResult(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(); -// } -// } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Models/ValidationResultTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Models/ValidationResultTests.cs new file mode 100644 index 00000000..7b151aac --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Models/ValidationResultTests.cs @@ -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 { "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 { "Detail 1" }; + + // Assert + result.IsValid.ShouldBeTrue(); + result.ErrorMessage.ShouldBe("Test error"); + result.Details.ShouldContain("Detail 1"); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/AppStatusRefreshServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/AppStatusRefreshServiceTests.cs new file mode 100644 index 00000000..90a34775 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/AppStatusRefreshServiceTests.cs @@ -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> _loggerMock; + private readonly Mock> _hubContextMock; + private readonly Mock _httpClientFactoryMock; + private readonly AppStatusSnapshot _snapshot; + private readonly JsonSerializerOptions _jsonOptions; + private readonly Mock _httpHandlerMock; + private AppStatusRefreshService? _service; + + public AppStatusRefreshServiceTests() + { + _loggerMock = new Mock>(); + _hubContextMock = new Mock>(); + _httpClientFactoryMock = new Mock(); + _snapshot = new AppStatusSnapshot(); + _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + _httpHandlerMock = new Mock(); + + // Setup hub context + var clientsMock = new Mock(); + var clientProxyMock = new Mock(); + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .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())).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 +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/JobManagementServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/JobManagementServiceTests.cs new file mode 100644 index 00000000..8e64286e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/JobManagementServiceTests.cs @@ -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> _loggerMock; + private readonly Mock _schedulerFactoryMock; + private readonly Mock _schedulerMock; + private readonly Mock> _hubContextMock; + private readonly JobManagementService _service; + + public JobManagementServiceTests() + { + _loggerMock = new Mock>(); + _schedulerFactoryMock = new Mock(); + _schedulerMock = new Mock(); + _hubContextMock = new Mock>(); + + _schedulerFactoryMock.Setup(f => f.GetScheduler(It.IsAny())) + .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(), It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _service.StartJob(jobType, directCronExpression: cronExpression); + + // Assert + Assert.False(result); + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("does not exist")), + It.IsAny(), + It.IsAny>()), + 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(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + _schedulerMock.Setup(s => s.ScheduleJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(DateTimeOffset.Now); + + // Act + var result = await _service.StartJob(jobType, directCronExpression: cronExpression); + + // Assert + Assert.True(result); + _schedulerMock.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny()), Times.Once); + _schedulerMock.Verify(s => s.ResumeJob(It.IsAny(), It.IsAny()), 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(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + _schedulerMock.Setup(s => s.ScheduleJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(DateTimeOffset.Now); + + // Act + var result = await _service.StartJob(jobType, schedule: schedule); + + // Assert + Assert.True(result); + _schedulerMock.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StartJob_WithNoScheduleOrCron_CreatesOneTimeTrigger() + { + // Arrange + var jobType = JobType.DownloadCleaner; + + _schedulerMock.Setup(s => s.CheckExists(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + _schedulerMock.Setup(s => s.ScheduleJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(DateTimeOffset.Now); + + // Act + var result = await _service.StartJob(jobType); + + // Assert + Assert.True(result); + _schedulerMock.Verify(s => s.ScheduleJob( + It.Is(t => t.Key.Name.Contains("onetime")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task StartJob_CleansUpExistingTriggers_BeforeSchedulingNew() + { + // Arrange + var jobType = JobType.QueueCleaner; + var cronExpression = "0 0/5 * * * ?"; + + var existingTriggerMock = new Mock(); + existingTriggerMock.Setup(t => t.Key).Returns(new TriggerKey("existing-trigger")); + + _schedulerMock.Setup(s => s.CheckExists(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { existingTriggerMock.Object }); + _schedulerMock.Setup(s => s.ScheduleJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(DateTimeOffset.Now); + + // Act + var result = await _service.StartJob(jobType, directCronExpression: cronExpression); + + // Assert + Assert.True(result); + _schedulerMock.Verify(s => s.UnscheduleJob( + It.Is(k => k.Name == "existing-trigger"), + It.IsAny()), 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(), It.IsAny())) + .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(), It.IsAny())) + .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(); + triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger")); + + _schedulerMock.Setup(s => s.CheckExists(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { triggerMock.Object }); + + // Act + var result = await _service.StopJob(jobType); + + // Assert + Assert.True(result); + _schedulerMock.Verify(s => s.UnscheduleJob(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StopJob_WhenSchedulerThrows_ReturnsFalse() + { + // Arrange + var jobType = JobType.QueueCleaner; + + _schedulerMock.Setup(s => s.CheckExists(It.IsAny(), It.IsAny())) + .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(), It.IsAny())) + .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(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + // 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(); + 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(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { triggerMock.Object }); + _schedulerMock.Setup(s => s.GetTriggerState(It.IsAny(), It.IsAny())) + .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(), It.IsAny())) + .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())) + .ReturnsAsync(new List()); + + // 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(); + 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())) + .ReturnsAsync(new List { "DEFAULT" }); + _schedulerMock.Setup(s => s.GetJobKeys(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new HashSet { jobKey }); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { triggerMock.Object }); + _schedulerMock.Setup(s => s.GetTriggerState(It.IsAny(), It.IsAny())) + .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())) + .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(), It.IsAny())) + .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(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.ScheduleJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(DateTimeOffset.Now); + + // Act + var result = await _service.TriggerJobOnce(jobType); + + // Assert + Assert.True(result); + _schedulerMock.Verify(s => s.ScheduleJob( + It.Is(t => t.Key.Name.Contains("immediate") && t.Key.Name.Contains("manual")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task TriggerJobOnce_WhenSchedulerThrows_ReturnsFalse() + { + // Arrange + var jobType = JobType.QueueCleaner; + + _schedulerMock.Setup(s => s.CheckExists(It.IsAny(), It.IsAny())) + .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(() => _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(), It.IsAny())) + .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(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + _schedulerMock.Setup(s => s.ScheduleJob(It.IsAny(), It.IsAny())) + .ReturnsAsync(DateTimeOffset.Now); + + // Act + var result = await _service.UpdateJobSchedule(jobType, schedule); + + // Assert + Assert.True(result); + _schedulerMock.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny()), 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(), It.IsAny())) + .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(), It.IsAny())) + .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(); + triggerMock.Setup(t => t.Key).Returns(expectedTriggerKey); + + _schedulerMock.Setup(s => s.CheckExists(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _schedulerMock.Setup(s => s.GetTrigger(expectedTriggerKey, It.IsAny())) + .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(), It.IsAny())) + .ThrowsAsync(new Exception("Scheduler error")); + + // Act + var result = await _service.GetMainTrigger(jobType); + + // Assert + Assert.Null(result); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/RuleEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/RuleEvaluatorTests.cs index 9b162456..1404910d 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Services/RuleEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/RuleEvaluatorTests.cs @@ -43,7 +43,7 @@ public class RuleEvaluatorTests }; ruleManagerMock - .Setup(x => x.GetMatchingStallRule(It.IsAny())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .Returns(stallRule); strikerMock @@ -56,13 +56,12 @@ public class RuleEvaluatorTests long downloadedBytes = 0; - var torrentMock = new Mock(); + var torrentMock = new Mock(); 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()); 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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(failingRule); strikerMock @@ -336,7 +335,7 @@ public class RuleEvaluatorTests maxTimeHours: 0); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); strikerMock @@ -374,7 +373,7 @@ public class RuleEvaluatorTests maxTimeHours: 2); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); strikerMock @@ -408,7 +407,7 @@ public class RuleEvaluatorTests maxTimeHours: 0); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); var torrentMock = CreateTorrentMock(); @@ -437,7 +436,7 @@ public class RuleEvaluatorTests maxTimeHours: 0); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); strikerMock @@ -469,7 +468,7 @@ public class RuleEvaluatorTests maxTimeHours: 0); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); var torrentMock = CreateTorrentMock(); @@ -497,7 +496,7 @@ public class RuleEvaluatorTests maxTimeHours: 2); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); var torrentMock = CreateTorrentMock(); @@ -525,7 +524,7 @@ public class RuleEvaluatorTests maxTimeHours: 0); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); strikerMock @@ -559,7 +558,7 @@ public class RuleEvaluatorTests maxTimeHours: 1); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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 CreateTorrentMock( + private static Mock CreateTorrentMock( Func? 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(); + var torrentMock = new Mock(); 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()); 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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingStallRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); strikerMock @@ -919,7 +917,7 @@ public class RuleEvaluatorTests deletePrivateTorrentsFromClient: true); ruleManagerMock - .Setup(x => x.GetMatchingSlowRule(It.IsAny())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .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())) + .Setup(x => x.GetMatchingSlowRule(It.IsAny())) .Returns(slowRule); strikerMock diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/RuleManagerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/RuleManagerTests.cs index 32725feb..581d3ee9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Services/RuleManagerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/RuleManagerTests.cs @@ -379,18 +379,17 @@ public class RuleManagerTests Assert.Equal(slowRule.Id, result.Id); } - private static Mock CreateTorrentMock( + private static Mock CreateTorrentMock( bool isPrivate = false, double completionPercentage = 50, string size = "100 MB") { - var torrentMock = new Mock(); + var torrentMock = new Mock(); 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()); torrentMock.SetupGet(t => t.DownloadedBytes).Returns(0); torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0); torrentMock.SetupGet(t => t.Eta).Returns(3600); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/StrikerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/StrikerTests.cs new file mode 100644 index 00000000..b1c97dd7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/StrikerTests.cs @@ -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 _logger; + private readonly EventPublisher _eventPublisher; + private readonly Striker _striker; + + public StrikerTests() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + _logger = Substitute.For>(); + + // Create EventPublisher with mocked dependencies + var eventsContext = CreateMockEventsContext(); + var hubContext = Substitute.For>(); + var hubClients = Substitute.For(); + var clientProxy = Substitute.For(); + hubContext.Clients.Returns(hubClients); + hubClients.All.Returns(clientProxy); + + var eventLogger = Substitute.For>(); + var notificationPublisher = Substitute.For(); + var dryRunInterceptor = Substitute.For(); + + // Configure dry run interceptor to just complete the task (we don't need actual DB saves in tests) + dryRunInterceptor + .InterceptAsync(Arg.Any(), Arg.Any()) + .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() + .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()); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/CronExpressionConverterTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/CronExpressionConverterTests.cs new file mode 100644 index 00000000..3c138b6a --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/CronExpressionConverterTests.cs @@ -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(() => 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(() => CronExpressionConverter.ConvertToCronExpression(null!)); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/CronValidationHelperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/CronValidationHelperTests.cs new file mode 100644 index 00000000..4d9261bb --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/CronValidationHelperTests.cs @@ -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( + () => 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( + () => 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( + () => 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( + () => 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( + () => 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( + () => 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)); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/ScheduleOptionsTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/ScheduleOptionsTests.cs new file mode 100644 index 00000000..27beac71 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Utilities/ScheduleOptionsTests.cs @@ -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(() => ScheduleOptions.GetValidValues(invalidUnit)); + } + + [Fact] + public void IsValidValue_InvalidUnit_ThrowsArgumentOutOfRangeException() + { + // Arrange + var invalidUnit = (ScheduleUnit)999; + + // Act & Assert + Should.Throw(() => 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"); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/xunit.runner.json b/code/backend/Cleanuparr.Infrastructure.Tests/xunit.runner.json new file mode 100644 index 00000000..403739b7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/xunit.runner.json @@ -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" +} diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs index 2c2b279e..6999a03b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs @@ -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; /// /// Service for publishing events to database and SignalR hub /// -public class EventPublisher +public class EventPublisher : IEventPublisher { private readonly EventsContext _context; private readonly IHubContext _appHubContext; diff --git a/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs new file mode 100644 index 00000000..8484abe7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs @@ -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); +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Extensions/TransmissionExtensions.cs b/code/backend/Cleanuparr.Infrastructure/Extensions/TransmissionExtensions.cs index 2c3ca8f6..1b0ed7d9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Extensions/TransmissionExtensions.cs +++ b/code/backend/Cleanuparr.Infrastructure/Extensions/TransmissionExtensions.cs @@ -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)); + } + + /// + /// Appends a category to the download directory of the torrent. + /// + 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(['\\', '/'])); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClientFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClientFactory.cs index 66235ce2..f405e56f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClientFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClientFactory.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrQueueIterator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrQueueIterator.cs index 9a9cfcf8..4a6ca4db 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrQueueIterator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrQueueIterator.cs @@ -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 _logger; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClientFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClientFactory.cs new file mode 100644 index 00000000..2ded7e98 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClientFactory.cs @@ -0,0 +1,8 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces; + +public interface IArrClientFactory +{ + IArrClient GetClient(InstanceType type); +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrQueueIterator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrQueueIterator.cs new file mode 100644 index 00000000..e824d2b9 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrQueueIterator.cs @@ -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, Task> action); +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs b/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs index b8bf8dc1..bb3a1467 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs @@ -19,14 +19,14 @@ public sealed class BlacklistSynchronizer : IHandler { private readonly ILogger _logger; private readonly DataContext _dataContext; - private readonly DownloadServiceFactory _downloadServiceFactory; + private readonly IDownloadServiceFactory _downloadServiceFactory; private readonly FileReader _fileReader; private readonly IDryRunInterceptor _dryRunInterceptor; public BlacklistSynchronizer( ILogger logger, DataContext dataContext, - DownloadServiceFactory downloadServiceFactory, + IDownloadServiceFactory downloadServiceFactory, FileReader fileReader, IDryRunInterceptor dryRunInterceptor ) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeClientWrapper.cs new file mode 100644 index 00000000..e01aa2b7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeClientWrapper.cs @@ -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 LoginAsync() + => _client.LoginAsync(); + + public Task IsConnected() + => _client.IsConnected(); + + public Task Connect() + => _client.Connect(); + + public Task GetTorrentStatus(string hash) + => _client.GetTorrentStatus(hash); + + public Task GetTorrentFiles(string hash) + => _client.GetTorrentFiles(hash); + + public Task GetTorrent(string hash) + => _client.GetTorrent(hash); + + public Task GetTorrentExtended(string hash) + => _client.GetTorrentExtended(hash); + + public Task?> GetStatusForAllTorrents() + => _client.GetStatusForAllTorrents(); + + public Task DeleteTorrents(List hashes) + => _client.DeleteTorrents(hashes); + + public Task ChangeFilesPriority(string hash, List priorities) + => _client.ChangeFilesPriority(hash, priorities); + + public Task> GetLabels() + => _client.GetLabels(); + + public Task CreateLabel(string label) + => _client.CreateLabel(label); + + public Task SetTorrentLabel(string hash, string newLabel) + => _client.SetTorrentLabel(hash, newLabel); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItem.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItem.cs deleted file mode 100644 index deb13990..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItem.cs +++ /dev/null @@ -1,115 +0,0 @@ -using Cleanuparr.Domain.Entities; -using Cleanuparr.Domain.Entities.Deluge.Response; -using Cleanuparr.Infrastructure.Services; - -namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; - -/// -/// Wrapper for Deluge DownloadStatus that implements ITorrentItem interface -/// -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 Trackers => _downloadStatus.Trackers? - .Where(t => !string.IsNullOrEmpty(t.Url)) - .Select(t => ExtractHostFromUrl(t.Url!)) - .Where(host => !string.IsNullOrEmpty(host)) - .Distinct() - .ToList() - .AsReadOnly() ?? (IReadOnlyList)Array.Empty(); - - // 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 Tags => Array.Empty(); // 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 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; - } - - /// - /// Extracts the host from a tracker URL - /// - 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; - } -} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs new file mode 100644 index 00000000..5b6fd3a4 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs @@ -0,0 +1,78 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Domain.Entities.Deluge.Response; +using Cleanuparr.Infrastructure.Services; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; + +/// +/// Wrapper for Deluge DownloadStatus that implements ITorrentItem interface +/// +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 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; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs index e085b96d..acdee9ab 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs @@ -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 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 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() diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs index 490c7aae..10f8fc57 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs @@ -10,99 +10,36 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; public partial class DelugeService { - public override async Task?> GetSeedingDownloads() + public override async Task> 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? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => + public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => downloads ?.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .ToList(); - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) => + public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) => downloads ?.Where(x => !string.IsNullOrEmpty(x.Hash)) .Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .ToList(); /// - public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, - IReadOnlyList 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(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? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) + public override async Task ChangeCategoryForNoHardLinksAsync(List? 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()) { - 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; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs index b3718f51..f89a3b77 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs @@ -12,8 +12,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; public partial class DelugeService { /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, - IReadOnlyList ignoredDownloads) + public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList 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); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/IDelugeClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/IDelugeClientWrapper.cs new file mode 100644 index 00000000..30cc0795 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/IDelugeClientWrapper.cs @@ -0,0 +1,20 @@ +using Cleanuparr.Domain.Entities.Deluge.Response; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; + +public interface IDelugeClientWrapper +{ + Task LoginAsync(); + Task IsConnected(); + Task Connect(); + Task GetTorrentStatus(string hash); + Task GetTorrentFiles(string hash); + Task GetTorrent(string hash); + Task GetTorrentExtended(string hash); + Task?> GetStatusForAllTorrents(); + Task DeleteTorrents(List hashes); + Task ChangeFilesPriority(string hash, List priorities); + Task> GetLabels(); + Task CreateLabel(string label); + Task SetTorrentLabel(string hash, string newLabel); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs index 2b064d64..58ac3c7a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs @@ -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 HealthCheckAsync(); - public abstract Task ShouldRemoveFromArrQueueAsync(string hash, - IReadOnlyList ignoredDownloads); + public abstract Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads); /// public abstract Task DeleteDownload(string hash); /// - public abstract Task?> GetSeedingDownloads(); + public abstract Task> GetSeedingDownloads(); /// - public abstract List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories); + public abstract List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories); /// - public abstract List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories); + public abstract List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories); /// - public abstract Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, IReadOnlyList ignoredDownloads); + public virtual async Task CleanDownloadsAsync(List? downloads, List 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(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); + } + } /// - public abstract Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads); - + public abstract Task ChangeCategoryForNoHardLinksAsync(List? downloads); + /// public abstract Task CreateCategoryAsync(string name); - + /// public abstract Task BlockUnwantedFilesAsync(string hash, IReadOnlyList ignoredDownloads); + + /// + /// Deletes the specified download from the download client. + /// Each client implementation handles the deletion according to its API requirements. + /// + /// The torrent to delete + 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; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs index b41aa42f..fb2a1d4f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs @@ -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; /// /// Factory responsible for creating download client service instances /// -public sealed class DownloadServiceFactory +public sealed class DownloadServiceFactory : IDownloadServiceFactory { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; @@ -66,7 +67,7 @@ public sealed class DownloadServiceFactory var dryRunInterceptor = _serviceProvider.GetRequiredService(); var hardLinkFileService = _serviceProvider.GetRequiredService(); var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); + var eventPublisher = _serviceProvider.GetRequiredService(); var blocklistProvider = _serviceProvider.GetRequiredService(); var ruleEvaluator = _serviceProvider.GetRequiredService(); @@ -90,7 +91,7 @@ public sealed class DownloadServiceFactory var dryRunInterceptor = _serviceProvider.GetRequiredService(); var hardLinkFileService = _serviceProvider.GetRequiredService(); var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); + var eventPublisher = _serviceProvider.GetRequiredService(); var blocklistProvider = _serviceProvider.GetRequiredService(); var ruleEvaluator = _serviceProvider.GetRequiredService(); @@ -114,7 +115,7 @@ public sealed class DownloadServiceFactory var dryRunInterceptor = _serviceProvider.GetRequiredService(); var hardLinkFileService = _serviceProvider.GetRequiredService(); var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); + var eventPublisher = _serviceProvider.GetRequiredService(); var blocklistProvider = _serviceProvider.GetRequiredService(); var ruleEvaluator = _serviceProvider.GetRequiredService(); @@ -138,7 +139,7 @@ public sealed class DownloadServiceFactory var dryRunInterceptor = _serviceProvider.GetRequiredService(); var hardLinkFileService = _serviceProvider.GetRequiredService(); var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); + var eventPublisher = _serviceProvider.GetRequiredService(); var blocklistProvider = _serviceProvider.GetRequiredService(); var loggerFactory = _serviceProvider.GetRequiredService(); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs index a881cfb7..92bfbc89 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs @@ -24,14 +24,13 @@ public interface IDownloadService : IDisposable /// /// The download hash. /// Downloads to ignore from processing. - public Task ShouldRemoveFromArrQueueAsync(string hash, - IReadOnlyList ignoredDownloads); + public Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads); /// /// Fetches all seeding downloads. /// /// A list of downloads that are seeding. - Task?> GetSeedingDownloads(); + Task> GetSeedingDownloads(); /// /// Filters downloads that should be cleaned. @@ -39,7 +38,7 @@ public interface IDownloadService : IDisposable /// The downloads to filter. /// The categories by which to filter the downloads. /// A list of downloads for the provided categories. - List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories); + List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories); /// /// Filters downloads that should have their category changed. @@ -47,24 +46,20 @@ public interface IDownloadService : IDisposable /// The downloads to filter. /// The categories by which to filter the downloads. /// A list of downloads for the provided categories. - List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories); + List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories); /// /// Cleans the downloads. /// /// The downloads to clean. /// The categories that should be cleaned. - /// The hashes that should not be cleaned. - /// The downloads to ignore from processing. - Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, IReadOnlyList ignoredDownloads); + Task CleanDownloadsAsync(List? downloads, List categoriesToClean); /// /// Changes the category for downloads that have no hardlinks. /// /// The downloads to change. - /// The hashes that should not be cleaned. - /// The downloads to ignore from processing. - Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads); + Task ChangeCategoryForNoHardLinksAsync(List? downloads); /// /// Deletes a download item. diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadServiceFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadServiceFactory.cs new file mode 100644 index 00000000..f0f5423c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadServiceFactory.cs @@ -0,0 +1,8 @@ +using Cleanuparr.Persistence.Models.Configuration; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +public interface IDownloadServiceFactory +{ + IDownloadService GetDownloadService(DownloadClientConfig downloadClientConfig); +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/IQBittorrentClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/IQBittorrentClientWrapper.cs new file mode 100644 index 00000000..1efc7116 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/IQBittorrentClientWrapper.cs @@ -0,0 +1,23 @@ +using QBittorrent.Client; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; + +/// +/// Wrapper interface for QBittorrentClient to enable testing +/// +public interface IQBittorrentClientWrapper : IDisposable +{ + Task LoginAsync(string username, string password); + Task GetApiVersionAsync(); + Task> GetTorrentListAsync(TorrentListQuery query); + Task GetTorrentPropertiesAsync(string hash); + Task> GetTorrentContentsAsync(string hash); + Task> GetTorrentTrackersAsync(string hash); + Task> GetCategoriesAsync(); + Task AddCategoryAsync(string category); + Task DeleteAsync(IEnumerable hashes, bool deleteDownloadedData); + Task AddTorrentTagAsync(IEnumerable hashes, string tag); + Task SetTorrentCategoryAsync(IEnumerable hashes, string category); + Task SetFilePriorityAsync(string hash, int fileIndex, TorrentContentPriority priority); + Task SetPreferencesAsync(Preferences preferences); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItem.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItem.cs deleted file mode 100644 index 06392d9f..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItem.cs +++ /dev/null @@ -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; - -/// -/// Wrapper for QBittorrent TorrentInfo that implements ITorrentItem interface -/// -public sealed class QBitItem : ITorrentItem -{ - private readonly TorrentInfo _torrentInfo; - private readonly IReadOnlyList _trackers; - private readonly bool _isPrivate; - - public QBitItem(TorrentInfo torrentInfo, IReadOnlyList 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 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 Tags => _torrentInfo.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList)Array.Empty(); - - // 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 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; - } - - /// - /// Extracts the host from a tracker URL - /// - 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; - } -} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs new file mode 100644 index 00000000..ed8c8d1e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs @@ -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; + +/// +/// Wrapper for QBittorrent TorrentInfo that implements ITorrentItem interface +/// +public sealed class QBitItemWrapper : ITorrentItemWrapper +{ + private readonly IReadOnlyList _trackers; + + public TorrentInfo Info { get; } + + public QBitItemWrapper(TorrentInfo torrentInfo, IReadOnlyList 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 Tags => Info.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList)Array.Empty(); + + 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 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; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs index ca1b21ac..20069ec4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs @@ -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 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 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() diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs index 14dd8bf3..6c1411d3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs @@ -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 { /// - public override async Task?> GetSeedingDownloads() + public override async Task> GetSeedingDownloads() { var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Completed }); if (torrentList is null) { - return null; + return []; } - var result = new List(); + var result = new List(); 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; } /// - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => + public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => downloads ?.Where(x => !string.IsNullOrEmpty(x.Hash)) .Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .ToList(); /// - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) + public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) { var downloadCleanerConfig = ContextProvider.Get(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 } /// - public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, - HashSet excludedHashes, IReadOnlyList 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(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? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) + public override async Task ChangeCategoryForNoHardLinksAsync(List? 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()) { - 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? files = await _client.GetTorrentContentsAsync(download.Hash); + + IReadOnlyList? 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; } } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs index 7f17074f..b260a393 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs @@ -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(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); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBittorrentClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBittorrentClientWrapper.cs new file mode 100644 index 00000000..f7024ec2 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBittorrentClientWrapper.cs @@ -0,0 +1,58 @@ +using QBittorrent.Client; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; + +/// +/// Concrete wrapper implementation for QBittorrentClient +/// +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 GetApiVersionAsync() + => _client.GetApiVersionAsync(); + + public Task> GetTorrentListAsync(TorrentListQuery query) + => _client.GetTorrentListAsync(query); + + public Task GetTorrentPropertiesAsync(string hash) + => _client.GetTorrentPropertiesAsync(hash); + + public Task> GetTorrentContentsAsync(string hash) + => _client.GetTorrentContentsAsync(hash); + + public Task> GetTorrentTrackersAsync(string hash) + => _client.GetTorrentTrackersAsync(hash); + + public Task> GetCategoriesAsync() + => _client.GetCategoriesAsync(); + + public Task AddCategoryAsync(string category) + => _client.AddCategoryAsync(category); + + public Task DeleteAsync(IEnumerable hashes, bool deleteDownloadedData) + => _client.DeleteAsync(hashes, deleteDownloadedData); + + public Task AddTorrentTagAsync(IEnumerable hashes, string tag) + => _client.AddTorrentTagAsync(hashes, tag); + + public Task SetTorrentCategoryAsync(IEnumerable 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(); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/ITransmissionClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/ITransmissionClientWrapper.cs new file mode 100644 index 00000000..5f566c6c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/ITransmissionClientWrapper.cs @@ -0,0 +1,16 @@ +using Transmission.API.RPC.Arguments; +using Transmission.API.RPC.Entity; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; + +/// +/// Wrapper interface for Transmission Client to enable testing +/// +public interface ITransmissionClientWrapper +{ + Task GetSessionInformationAsync(); + Task TorrentGetAsync(string[] fields, string? hash = null); + Task TorrentSetAsync(TorrentSettings settings); + Task TorrentSetLocationAsync(long[] ids, string location, bool move); + Task TorrentRemoveAsync(long[] ids, bool deleteLocalData); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionClientWrapper.cs new file mode 100644 index 00000000..2e322602 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionClientWrapper.cs @@ -0,0 +1,33 @@ +using Transmission.API.RPC; +using Transmission.API.RPC.Arguments; +using Transmission.API.RPC.Entity; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; + +/// +/// Concrete wrapper implementation for Transmission Client +/// +public sealed class TransmissionClientWrapper : ITransmissionClientWrapper +{ + private readonly Client _client; + + public TransmissionClientWrapper(Client client) + { + _client = client; + } + + public Task GetSessionInformationAsync() + => _client.GetSessionInformationAsync(); + + public Task 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); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItem.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItem.cs deleted file mode 100644 index b265cc0c..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItem.cs +++ /dev/null @@ -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; - -/// -/// Wrapper for Transmission TorrentInfo that implements ITorrentItem interface -/// -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 Trackers => _torrentInfo.Trackers? - .Where(t => !string.IsNullOrEmpty(t.Announce)) - .Select(t => ExtractHostFromUrl(t.Announce!)) - .Where(host => !string.IsNullOrEmpty(host)) - .Distinct() - .ToList() - .AsReadOnly() ?? (IReadOnlyList)Array.Empty(); - - // 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 Tags => _torrentInfo.Labels?.ToList().AsReadOnly() ?? (IReadOnlyList)Array.Empty(); - - // 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 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; - } - - /// - /// Extracts the host from a tracker URL - /// - 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; - } -} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs new file mode 100644 index 00000000..cac1a04a --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs @@ -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; + +/// +/// Wrapper for Transmission TorrentInfo that implements ITorrentItem interface +/// +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 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; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs index b9141092..a3692be1 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs @@ -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 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() diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs index 76e05ebb..0556c2f7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs @@ -1,4 +1,4 @@ -using Cleanuparr.Domain.Entities; +using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Extensions; using Cleanuparr.Infrastructure.Features.Context; @@ -10,18 +10,18 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; public partial class TransmissionService { - public override async Task?> GetSeedingDownloads() + public override async Task> GetSeedingDownloads() { var result = await _client.TorrentGetAsync(Fields); return result?.Torrents ?.Where(x => !string.IsNullOrEmpty(x.HashString)) .Where(x => x.Status is 5 or 6) - .Select(x => (ITorrentItem)new TransmissionItem(x)) - .ToList(); + .Select(ITorrentItemWrapper (x) => new TransmissionItemWrapper(x)) + .ToList() ?? []; } /// - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) + public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) { return downloads ?.Where(x => categories @@ -30,7 +30,7 @@ public partial class TransmissionService .ToList(); } - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) + public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) { return downloads ?.Where(x => !string.IsNullOrEmpty(x.Hash)) @@ -39,80 +39,10 @@ public partial class TransmissionService } /// - public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, - HashSet excludedHashes, IReadOnlyList 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.LogDebug("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(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; - } - - // Get the underlying TorrentInfo to access Id for deletion - TorrentInfo? torrentInfo = await GetTorrentAsync(download.Hash); - if (torrentInfo is null) - { - _logger.LogDebug("failed to find torrent info for {name}", download.Name); - continue; - } - - await _dryRunInterceptor.InterceptAsync(RemoveDownloadAsync, torrentInfo.Id); - - _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); - } + var transmissionTorrent = (TransmissionItemWrapper)torrent; + await RemoveDownloadAsync(transmissionTorrent.Info.Id); } public override async Task CreateCategoryAsync(string name) @@ -120,7 +50,7 @@ public partial class TransmissionService await Task.CompletedTask; } - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) + public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads) { if (downloads?.Count is null or 0) { @@ -134,62 +64,43 @@ public partial class TransmissionService _hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir); } - foreach (ITorrentItem download in downloads) + foreach (TransmissionItemWrapper torrent in downloads.Cast()) { - if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name)) + if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Info.DownloadDir)) { continue; } + + ContextProvider.Set("downloadName", torrent.Name); + ContextProvider.Set("hash", torrent.Hash); - if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) + if (torrent.Info.Files is null || torrent.Info.FileStats is null) { - _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); + _logger.LogDebug("skip | download has no files | {name}", torrent.Name); continue; } - - if (download.IsIgnored(ignoredDownloads)) - { - _logger.LogDebug("skip | download is ignored | {name}", download.Name); - continue; - } - - ContextProvider.Set("downloadName", download.Name); - ContextProvider.Set("hash", download.Hash); - - // Get the underlying TorrentInfo to access files and DownloadDir - TorrentInfo? torrentInfo = await GetTorrentAsync(download.Hash); - if (torrentInfo is null || torrentInfo.DownloadDir is null) - { - _logger.LogDebug("failed to find torrent info for {name}", download.Name); - continue; - } - + bool hasHardlinks = false; + bool hasErrors = false; - if (torrentInfo.Files is null || torrentInfo.FileStats is null) + for (int i = 0; i < torrent.Info.Files.Length; i++) { - _logger.LogDebug("skip | download has no files | {name}", download.Name); - continue; - } - - for (int i = 0; i < torrentInfo.Files.Length; i++) - { - TransmissionTorrentFiles file = torrentInfo.Files[i]; - TransmissionTorrentFileStats stats = torrentInfo.FileStats[i]; + TransmissionTorrentFiles file = torrent.Info.Files[i]; + TransmissionTorrentFileStats stats = torrent.Info.FileStats[i]; if (stats.Wanted is null or false || string.IsNullOrEmpty(file.Name)) { continue; } - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrentInfo.DownloadDir, file.Name).Split(['\\', '/'])); + string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadDir, file.Name).Split(['\\', '/'])); long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir)); 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; } @@ -200,20 +111,27 @@ public partial class TransmissionService } } - if (hasHardlinks) + if (hasErrors) { - _logger.LogDebug("skip | download has hardlinks | {name}", download.Name); continue; } - string currentCategory = download.Category ?? string.Empty; - string newLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrentInfo.DownloadDir, downloadCleanerConfig.UnlinkedTargetCategory).Split(['\\', '/'])); + if (hasHardlinks) + { + _logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name); + continue; + } - await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, torrentInfo.Id, newLocation); + string currentCategory = torrent.Category ?? string.Empty; + string newLocation = torrent.Info.GetNewLocationByAppend(downloadCleanerConfig.UnlinkedTargetCategory); - _logger.LogInformation("category changed for {name}", download.Name); + await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, torrent.Info.Id, newLocation); + + _logger.LogInformation("category changed for {name}", torrent.Name); await _eventPublisher.PublishCategoryChanged(currentCategory, downloadCleanerConfig.UnlinkedTargetCategory); + + torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs index 0c1bcd0e..2454f51f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs @@ -13,15 +13,14 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; public partial class TransmissionService { /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, - IReadOnlyList ignoredDownloads) + public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) { DownloadCheckResult result = new(); TorrentInfo? download = await GetTorrentAsync(hash); 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; } @@ -30,11 +29,11 @@ public partial class TransmissionService result.Found = true; // Create ITorrentItem wrapper for consistent interface usage - var torrentItem = new TransmissionItem(download); + TransmissionItemWrapper torrent = new(download); - if (torrentItem.IsIgnored(ignoredDownloads)) + if (torrent.IsIgnored(ignoredDownloads)) { - _logger.LogDebug("skip | download is ignored | {name}", torrentItem.Name); + _logger.LogDebug("skip | download is ignored | {name}", torrent.Name); return result; } @@ -58,7 +57,7 @@ public partial class TransmissionService if (shouldRemove) { // 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.ShouldRemove = true; result.DeleteReason = DeleteReason.AllFilesSkipped; result.DeleteFromClient = true; @@ -66,7 +65,7 @@ public partial class TransmissionService } // 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; } @@ -80,44 +79,44 @@ public partial class TransmissionService }); } - 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); } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/IUTorrentClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/IUTorrentClientWrapper.cs new file mode 100644 index 00000000..4d27d89b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/IUTorrentClientWrapper.cs @@ -0,0 +1,17 @@ +using Cleanuparr.Domain.Entities.UTorrent.Response; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; + +public interface IUTorrentClientWrapper +{ + Task LoginAsync(); + Task TestConnectionAsync(); + Task> GetTorrentsAsync(); + Task GetTorrentAsync(string hash); + Task?> GetTorrentFilesAsync(string hash); + Task GetTorrentPropertiesAsync(string hash); + Task> GetLabelsAsync(); + Task SetTorrentLabelAsync(string hash, string label); + Task SetFilesPriorityAsync(string hash, List fileIndexes, int priority); + Task RemoveTorrentsAsync(List hashes); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClient.cs index 03a2fec2..55f271bd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClient.cs @@ -227,18 +227,6 @@ public sealed class UTorrentClient } } - /// - /// Creates a new label in µTorrent - /// - /// Label name to create - public static async Task CreateLabel(string label) - { - // µTorrent doesn't have an explicit "create label" API - // Labels are created automatically when you assign them to a torrent - // So this is a no-op for µTorrent - await Task.CompletedTask; - } - /// /// Sends an authenticated request to the µTorrent API /// Handles automatic authentication and retry logic diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClientWrapper.cs new file mode 100644 index 00000000..0c275458 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentClientWrapper.cs @@ -0,0 +1,43 @@ +using Cleanuparr.Domain.Entities.UTorrent.Response; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; + +public sealed class UTorrentClientWrapper : IUTorrentClientWrapper +{ + private readonly UTorrentClient _client; + + public UTorrentClientWrapper(UTorrentClient client) + { + _client = client; + } + + public Task LoginAsync() + => _client.LoginAsync(); + + public Task TestConnectionAsync() + => _client.TestConnectionAsync(); + + public Task> GetTorrentsAsync() + => _client.GetTorrentsAsync(); + + public Task GetTorrentAsync(string hash) + => _client.GetTorrentAsync(hash); + + public Task?> GetTorrentFilesAsync(string hash) + => _client.GetTorrentFilesAsync(hash); + + public Task GetTorrentPropertiesAsync(string hash) + => _client.GetTorrentPropertiesAsync(hash); + + public Task> GetLabelsAsync() + => _client.GetLabelsAsync(); + + public Task SetTorrentLabelAsync(string hash, string label) + => _client.SetTorrentLabelAsync(hash, label); + + public Task SetFilesPriorityAsync(string hash, List fileIndexes, int priority) + => _client.SetFilesPriorityAsync(hash, fileIndexes, priority); + + public Task RemoveTorrentsAsync(List hashes) + => _client.RemoveTorrentsAsync(hashes); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs index 65f917d0..1c86d85c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs @@ -7,74 +7,50 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; /// /// Wrapper for UTorrent UTorrentItem and UTorrentProperties that implements ITorrentItem interface /// -public sealed class UTorrentItemWrapper : ITorrentItem +public sealed class UTorrentItemWrapper : ITorrentItemWrapper { - private readonly UTorrentItem _torrentItem; - private readonly UTorrentProperties _torrentProperties; + public UTorrentItem Info { get; } + + public UTorrentProperties Properties { get; } public UTorrentItemWrapper(UTorrentItem torrentItem, UTorrentProperties torrentProperties) { - _torrentItem = torrentItem ?? throw new ArgumentNullException(nameof(torrentItem)); - _torrentProperties = torrentProperties ?? throw new ArgumentNullException(nameof(torrentProperties)); + Info = torrentItem ?? throw new ArgumentNullException(nameof(torrentItem)); + Properties = torrentProperties ?? throw new ArgumentNullException(nameof(torrentProperties)); } - // Basic identification - public string Hash => _torrentItem.Hash; - public string Name => _torrentItem.Name; + public string Hash => Info.Hash; + + public string Name => Info.Name; - // Privacy and tracking - public bool IsPrivate => _torrentProperties.IsPrivate; - public IReadOnlyList Trackers => _torrentProperties.TrackerList - .Select(ExtractHostFromUrl) - .Where(host => !string.IsNullOrEmpty(host)) - .Distinct() - .ToList() - .AsReadOnly(); + public bool IsPrivate => Properties.IsPrivate; - // Size and progress - public long Size => _torrentItem.Size; - public double CompletionPercentage => _torrentItem.Progress / 10.0; // Progress is in permille (1000 = 100%) - public long DownloadedBytes => _torrentItem.Downloaded; - public long TotalUploaded => _torrentItem.Uploaded; + public long Size => Info.Size; + + public double CompletionPercentage => Info.Progress / 10.0; // Progress is in permille (1000 = 100%) + + public long DownloadedBytes => Info.Downloaded; - // Speed and transfer rates - public long DownloadSpeed => _torrentItem.DownloadSpeed; - public long UploadSpeed => _torrentItem.UploadSpeed; - public double Ratio => _torrentItem.Ratio; + public long DownloadSpeed => Info.DownloadSpeed; + + public double Ratio => Info.Ratio; - // Time tracking - public long Eta => _torrentItem.ETA; - public DateTime? DateAdded => _torrentItem.DateAdded > 0 - ? DateTimeOffset.FromUnixTimeSeconds(_torrentItem.DateAdded).DateTime - : null; - public DateTime? DateCompleted => _torrentItem.DateCompletedDateTime; - public long SeedingTimeSeconds => (long?)_torrentItem.SeedingTime?.TotalSeconds ?? 0; + public long Eta => Info.ETA; + + public long SeedingTimeSeconds => (long?)Info.SeedingTime?.TotalSeconds ?? 0; - // Categories and tags - public string? Category => _torrentItem.Label; - public IReadOnlyList Tags => Array.Empty(); // uTorrent doesn't have tags + public string? Category + { + get => Info.Label; + set => Info.Label = value ?? throw new ArgumentNullException(nameof(value)); + } - // State checking methods using status bitfield public bool IsDownloading() => - (_torrentItem.Status & UTorrentStatus.Started) != 0 && - (_torrentItem.Status & UTorrentStatus.Checked) != 0 && - (_torrentItem.Status & UTorrentStatus.Error) == 0; + (Info.Status & UTorrentStatus.Started) != 0 && + (Info.Status & UTorrentStatus.Checked) != 0 && + (Info.Status & UTorrentStatus.Error) == 0; - public bool IsStalled() => IsDownloading() && _torrentItem.DownloadSpeed == 0 && _torrentItem.ETA == 0; - - public bool IsSeeding() => IsDownloading() && _torrentItem.DateCompleted > 0; - - public bool IsCompleted() => _torrentItem.ProgressPercent >= 1.0; - - public bool IsPaused() => (_torrentItem.Status & UTorrentStatus.Paused) != 0; - - public bool IsQueued() => (_torrentItem.Status & UTorrentStatus.Queued) != 0; - - public bool IsChecking() => (_torrentItem.Status & UTorrentStatus.Checking) != 0; - - public bool IsAllocating() => false; // uTorrent doesn't have a specific allocating state - - public bool IsMetadataDownloading() => false; // uTorrent doesn't have this state + public bool IsStalled() => IsDownloading() && Info is { DownloadSpeed: 0, ETA: 0 }; // Filtering methods public bool IsIgnored(IReadOnlyList ignoredDownloads) @@ -96,7 +72,7 @@ public sealed class UTorrentItemWrapper : ITorrentItem return true; } - if (_torrentProperties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))) + if (Properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))) { return true; } @@ -104,24 +80,4 @@ public sealed class UTorrentItemWrapper : ITorrentItem return false; } - - /// - /// Extracts the host from a tracker URL - /// - 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; - } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs index 07d7f360..a6708fd4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs @@ -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; @@ -17,7 +18,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; /// public partial class UTorrentService : DownloadService, IUTorrentService { - private readonly UTorrentClient _client; + private readonly IUTorrentClientWrapper _client; public UTorrentService( ILogger logger, @@ -27,7 +28,7 @@ public partial class UTorrentService : DownloadService, IUTorrentService IDryRunInterceptor dryRunInterceptor, IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, - EventPublisher eventPublisher, + IEventPublisher eventPublisher, BlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, ILoggerFactory loggerFactory, @@ -48,14 +49,39 @@ public partial class UTorrentService : DownloadService, IUTorrentService loggerFactory.CreateLogger() ); var responseParser = new UTorrentResponseParser(loggerFactory.CreateLogger()); - - _client = new UTorrentClient( + + var client = new UTorrentClient( downloadClientConfig, authenticator, httpService, responseParser, loggerFactory.CreateLogger() ); + _client = new UTorrentClientWrapper(client); + } + + // Internal constructor for testing + internal UTorrentService( + ILogger logger, + IMemoryCache cache, + IFilenameEvaluator filenameEvaluator, + IStriker striker, + IDryRunInterceptor dryRunInterceptor, + IHardLinkFileService hardLinkFileService, + IDynamicHttpClientProvider httpClientProvider, + IEventPublisher eventPublisher, + BlocklistProvider blocklistProvider, + DownloadClientConfig downloadClientConfig, + IRuleEvaluator ruleEvaluator, + IRuleManager ruleManager, + IUTorrentClientWrapper clientWrapper + ) : base( + logger, cache, + filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService, + httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager + ) + { + _client = clientWrapper; } public override void Dispose() diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs index fdfa0c3d..0d94e3f9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs @@ -1,7 +1,6 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.UTorrent.Response; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Extensions; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; @@ -11,12 +10,12 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; public partial class UTorrentService { - public override async Task?> GetSeedingDownloads() + public override async Task> GetSeedingDownloads() { var torrents = await _client.GetTorrentsAsync(); - var result = new List(); + var result = new List(); - foreach (var torrent in torrents.Where(x => !string.IsNullOrEmpty(x.Hash) && x.IsSeeding())) + foreach (UTorrentItem torrent in torrents.Where(x => !string.IsNullOrEmpty(x.Hash) && x.IsSeeding())) { var properties = await _client.GetTorrentPropertiesAsync(torrent.Hash); result.Add(new UTorrentItemWrapper(torrent, properties)); @@ -25,101 +24,29 @@ public partial class UTorrentService return result; } - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => + public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => downloads ?.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .ToList(); - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) => + public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) => downloads ?.Where(x => !string.IsNullOrEmpty(x.Hash)) .Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .ToList(); /// - public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, - IReadOnlyList 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(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) { - var existingLabels = await _client.GetLabelsAsync(); - - if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase)) - { - return; - } - - _logger.LogDebug("Creating category {name}", name); - - await _dryRunInterceptor.InterceptAsync(CreateLabel, name); + await Task.CompletedTask; } - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) + public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads) { if (downloads?.Count is null or 0) { @@ -133,43 +60,24 @@ public partial class UTorrentService _hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir); } - foreach (ITorrentItem download in downloads) + foreach (UTorrentItemWrapper torrent in downloads.Cast()) { - 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 UTorrentItem to access SavePath - UTorrentItem? torrentItem = await _client.GetTorrentAsync(download.Hash); - if (torrentItem is null) - { - _logger.LogDebug("failed to find torrent for {name}", download.Name); - continue; - } - - List? files = await _client.GetTorrentFilesAsync(download.Hash); + List? files = await _client.GetTorrentFilesAsync(torrent.Hash); bool hasHardlinks = false; + bool hasErrors = false; foreach (var file in files ?? []) { - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrentItem.SavePath, file.Name).Split(['\\', '/'])); + string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.SavePath, file.Name).Split(['\\', '/'])); if (file.Priority <= 0) { @@ -182,8 +90,8 @@ public partial class UTorrentService 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; } @@ -194,17 +102,24 @@ public partial class UTorrentService } } - 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; + } - await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory); + await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory); - _logger.LogInformation("category changed for {name}", download.Name); + await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory); + + _logger.LogInformation("category changed for {name}", torrent.Name); + + torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory; } } @@ -215,11 +130,6 @@ public partial class UTorrentService await _client.RemoveTorrentsAsync([hash]); } - - protected async Task CreateLabel(string name) - { - await UTorrentClient.CreateLabel(name); - } protected virtual async Task ChangeLabel(string hash, string newLabel) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs index ab67599e..2a35fa4a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs @@ -21,7 +21,7 @@ public partial class UTorrentService if (download?.Hash is null) { - _logger.LogDebug("Failed to find torrent {hash} in the download client", hash); + _logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name); return result; } @@ -30,11 +30,11 @@ public partial class UTorrentService result.Found = true; // Create ITorrentItem wrapper for consistent interface usage - var torrentItem = new UTorrentItemWrapper(download, properties); + UTorrentItemWrapper torrent = new(download, properties); - 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; } @@ -61,7 +61,7 @@ public partial class UTorrentService if (shouldRemove) { // 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.ShouldRemove = true; result.DeleteReason = DeleteReason.AllFilesSkipped; result.DeleteFromClient = true; @@ -69,49 +69,49 @@ public partial class UTorrentService } // 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); } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/DownloadHunter.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/DownloadHunter.cs index 0574b9b7..86b8629c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/DownloadHunter.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/DownloadHunter.cs @@ -1,4 +1,5 @@ using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; using Cleanuparr.Persistence; @@ -11,15 +12,18 @@ namespace Cleanuparr.Infrastructure.Features.DownloadHunter; public sealed class DownloadHunter : IDownloadHunter { private readonly DataContext _dataContext; - private readonly ArrClientFactory _arrClientFactory; - + private readonly IArrClientFactory _arrClientFactory; + private readonly TimeProvider _timeProvider; + public DownloadHunter( DataContext dataContext, - ArrClientFactory arrClientFactory + IArrClientFactory arrClientFactory, + TimeProvider timeProvider ) { _dataContext = dataContext; _arrClientFactory = arrClientFactory; + _timeProvider = timeProvider; } public async Task HuntDownloadsAsync(DownloadHuntRequest request) @@ -44,6 +48,6 @@ public sealed class DownloadHunter : IDownloadHunter } // Prevent tracker spamming - await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay)); + await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay), _timeProvider); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs index d4e19c1d..a87de4bd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -2,7 +2,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; +using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; @@ -22,15 +24,15 @@ public sealed class QueueItemRemover : IQueueItemRemover private readonly ILogger _logger; private readonly IBus _messageBus; private readonly IMemoryCache _cache; - private readonly ArrClientFactory _arrClientFactory; - private readonly EventPublisher _eventPublisher; + private readonly IArrClientFactory _arrClientFactory; + private readonly IEventPublisher _eventPublisher; public QueueItemRemover( ILogger logger, IBus messageBus, IMemoryCache cache, - ArrClientFactory arrClientFactory, - EventPublisher eventPublisher + IArrClientFactory arrClientFactory, + IEventPublisher eventPublisher ) { _logger = logger; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs index 191fe843..5cf4ec54 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; +using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Caching.Memory; @@ -13,11 +14,11 @@ public sealed class Striker : IStriker private readonly ILogger _logger; private readonly IMemoryCache _cache; private readonly MemoryCacheEntryOptions _cacheOptions; - private readonly EventPublisher _eventPublisher; + private readonly IEventPublisher _eventPublisher; public static readonly ConcurrentDictionary RecurringHashes = []; - public Striker(ILogger logger, IMemoryCache cache, EventPublisher eventPublisher) + public Striker(ILogger logger, IMemoryCache cache, IEventPublisher eventPublisher) { _logger = logger; _cache = cache; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs index 530bd424..3dae0746 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs @@ -1,8 +1,7 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Events; -using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; @@ -20,22 +19,25 @@ namespace Cleanuparr.Infrastructure.Features.Jobs; public sealed class DownloadCleaner : GenericHandler { - private readonly HashSet _excludedHashes = []; - + private readonly HashSet _downloadsProcessedByArrs = []; + private readonly TimeProvider _timeProvider; + public DownloadCleaner( ILogger logger, DataContext dataContext, IMemoryCache cache, IBus messageBus, - ArrClientFactory arrClientFactory, - ArrQueueIterator arrArrQueueIterator, - DownloadServiceFactory downloadServiceFactory, - EventPublisher eventPublisher + IArrClientFactory arrClientFactory, + IArrQueueIterator arrArrQueueIterator, + IDownloadServiceFactory downloadServiceFactory, + IEventPublisher eventPublisher, + TimeProvider timeProvider ) : base( logger, dataContext, cache, messageBus, arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher ) { + _timeProvider = timeProvider; } protected override async Task ExecuteInternalAsync() @@ -62,15 +64,16 @@ public sealed class DownloadCleaner : GenericHandler List ignoredDownloads = ContextProvider.Get(nameof(GeneralConfig)).IgnoredDownloads; ignoredDownloads.AddRange(ContextProvider.Get().IgnoredDownloads); - var downloadServiceToDownloadsMap = new Dictionary>(); + var downloadServiceToDownloadsMap = new Dictionary>(); foreach (var downloadService in downloadServices) { try { await downloadService.LoginAsync(); - var clientDownloads = await downloadService.GetSeedingDownloads(); - if (clientDownloads?.Count > 0) + List clientDownloads = await downloadService.GetSeedingDownloads(); + + if (clientDownloads.Count > 0) { downloadServiceToDownloadsMap[downloadService] = clientDownloads; } @@ -81,116 +84,52 @@ public sealed class DownloadCleaner : GenericHandler } } - if (downloadServiceToDownloadsMap.Count == 0) + if (downloadServiceToDownloadsMap.Count is 0) { - _logger.LogDebug("no seeding downloads found"); + _logger.LogInformation("No seeding downloads found"); return; } - var totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count); - _logger.LogTrace("found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count); - - List>> downloadServiceWithDownloads = []; - - if (isUnlinkedEnabled) - { - // Create category for all clients - foreach (var downloadService in downloadServices) - { - try - { - await downloadService.CreateCategoryAsync(config.UnlinkedTargetCategory); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to create category for download client {clientName}", downloadService.ClientConfig.Name); - } - } - - foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap) - { - try - { - var downloadsToChangeCategory = downloadService.FilterDownloadsToChangeCategoryAsync(clientDownloads, config.UnlinkedCategories); - if (downloadsToChangeCategory?.Count > 0) - { - downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToChangeCategory)); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to filter downloads for category change for download client {clientName}", downloadService.ClientConfig.Name); - } - } - } + int totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count); + _logger.LogTrace("Found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count); // wait for the downloads to appear in the arr queue - await Task.Delay(10 * 1000); + await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Radarr)), InstanceType.Radarr, true); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Readarr)), InstanceType.Readarr, true); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true); - - if (isUnlinkedEnabled && downloadServiceWithDownloads.Sum(x => x.Item2.Count) > 0) + + foreach (var pair in downloadServiceToDownloadsMap) { - _logger.LogInformation("Evaluating {count} downloads for hardlinks", downloadServiceWithDownloads.Sum(x => x.Item2.Count)); + List filteredDownloads = []; - // Process each client with its own filtered downloads - foreach (var (downloadService, downloadsToChangeCategory) in downloadServiceWithDownloads) + foreach (ITorrentItemWrapper download in pair.Value) { - try + if (download.IsIgnored(ignoredDownloads)) { - await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads); + _logger.LogDebug("skip | download is ignored | {name}", download.Name); + continue; } - catch (Exception ex) + + if (_downloadsProcessedByArrs.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) { - _logger.LogError(ex, "Failed to change category for download client {clientName}", downloadService.ClientConfig.Name); + _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); + continue; } + + filteredDownloads.Add(download); } - - _logger.LogInformation("Finished hardlinks evaluation"); + + downloadServiceToDownloadsMap[pair.Key] = filteredDownloads; } + + await ChangeUnlinkedCategoriesAsync(isUnlinkedEnabled, downloadServiceToDownloadsMap, config); + await CleanDownloadsAsync(downloadServiceToDownloadsMap, config); - if (config.Categories.Count is 0) - { - return; - } - downloadServiceWithDownloads = []; - foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap) - { - try - { - var downloadsToClean = downloadService.FilterDownloadsToBeCleanedAsync(clientDownloads, config.Categories); - if (downloadsToClean?.Count > 0) - { - downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToClean)); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to filter downloads for cleaning for download client {clientName}", downloadService.ClientConfig.Name); - } - } - - _logger.LogInformation("Evaluating {count} downloads for cleanup", downloadServiceWithDownloads.Sum(x => x.Item2.Count)); - - // Process cleaning for each client - foreach (var (downloadService, downloadsToClean) in downloadServiceWithDownloads) - { - try - { - await downloadService.CleanDownloadsAsync(downloadsToClean, config.Categories, _excludedHashes, ignoredDownloads); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to clean downloads for download client {clientName}", downloadService.ClientConfig.Name); - } - } - - _logger.LogInformation("Finished cleanup evaluation"); foreach (var downloadService in downloadServices) { @@ -213,8 +152,132 @@ public sealed class DownloadCleaner : GenericHandler foreach (QueueRecord record in groups.Select(group => group.First())) { - _excludedHashes.Add(record.DownloadId.ToLowerInvariant()); + _downloadsProcessedByArrs.Add(record.DownloadId.ToLowerInvariant()); } }); } + + private async Task ChangeUnlinkedCategoriesAsync(bool isUnlinkedEnabled, Dictionary> downloadServiceToDownloadsMap, DownloadCleanerConfig config) + { + if (!isUnlinkedEnabled) + { + return; + } + + Dictionary> downloadServiceWithDownloads = []; + + foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap) + { + try + { + var downloadsToChangeCategory = downloadService + .FilterDownloadsToChangeCategoryAsync(clientDownloads, config.UnlinkedCategories); + + if (downloadsToChangeCategory?.Count > 0) + { + downloadServiceWithDownloads.Add(downloadService, downloadsToChangeCategory); + } + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to filter downloads for hardlinks evaluation for download client {clientName}", + downloadService.ClientConfig.Name + ); + } + } + + if (downloadServiceWithDownloads.Count is 0) + { + _logger.LogInformation("No downloads found to evaluate for hardlinks"); + return; + } + + _logger.LogInformation( + "Evaluating {count} downloads for hardlinks", + downloadServiceWithDownloads.Sum(x => x.Value.Count) + ); + + // Process each client with its own filtered downloads + foreach (var (downloadService, downloadsToChangeCategory) in downloadServiceWithDownloads) + { + try + { + await downloadService.CreateCategoryAsync(config.UnlinkedTargetCategory); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to create category {category} for download client {clientName}", + config.UnlinkedTargetCategory, + downloadService.ClientConfig.Name + ); + } + + try + { + await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to change category for download client {clientName}", downloadService.ClientConfig.Name); + } + } + + _logger.LogInformation("Finished hardlinks evaluation"); + } + + private async Task CleanDownloadsAsync(Dictionary> downloadServiceToDownloadsMap, DownloadCleanerConfig config) + { + if (config.Categories.Count is 0) + { + return; + } + + Dictionary> downloadServiceWithDownloads = []; + + foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap) + { + try + { + var downloadsToClean = downloadService + .FilterDownloadsToBeCleanedAsync(clientDownloads, config.Categories); + + if (downloadsToClean?.Count > 0) + { + downloadServiceWithDownloads.Add(downloadService, downloadsToClean); + } + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to filter downloads for cleaning for download client {clientName}", + downloadService.ClientConfig.Name + ); + } + } + + _logger.LogInformation( + "Evaluating {count} downloads for cleanup", + downloadServiceWithDownloads.Sum(x => x.Value.Count) + ); + + // Process cleaning for each client + foreach (var (downloadService, downloadsToClean) in downloadServiceWithDownloads) + { + try + { + await downloadService.CleanDownloadsAsync(downloadsToClean, config.Categories); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to clean downloads for download client {clientName}", downloadService.ClientConfig.Name); + } + } + + _logger.LogInformation("Finished cleanup evaluation"); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs index 69348801..afef33d3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs @@ -1,8 +1,8 @@ using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Events; -using Cleanuparr.Infrastructure.Features.Arr; +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.DownloadRemover.Models; @@ -27,20 +27,20 @@ public abstract class GenericHandler : IHandler protected readonly DataContext _dataContext; protected readonly IMemoryCache _cache; protected readonly IBus _messageBus; - protected readonly ArrClientFactory _arrClientFactory; - protected readonly ArrQueueIterator _arrArrQueueIterator; - protected readonly DownloadServiceFactory _downloadServiceFactory; - private readonly EventPublisher _eventPublisher; + protected readonly IArrClientFactory _arrClientFactory; + protected readonly IArrQueueIterator _arrArrQueueIterator; + protected readonly IDownloadServiceFactory _downloadServiceFactory; + private readonly IEventPublisher _eventPublisher; protected GenericHandler( ILogger logger, DataContext dataContext, IMemoryCache cache, IBus messageBus, - ArrClientFactory arrClientFactory, - ArrQueueIterator arrArrQueueIterator, - DownloadServiceFactory downloadServiceFactory, - EventPublisher eventPublisher + IArrClientFactory arrClientFactory, + IArrQueueIterator arrArrQueueIterator, + IDownloadServiceFactory downloadServiceFactory, + IEventPublisher eventPublisher ) { _logger = logger; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/JobChainingListener.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/JobChainingListener.cs deleted file mode 100644 index 009b1db1..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/JobChainingListener.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Quartz; - -namespace Cleanuparr.Infrastructure.Features.Jobs; - -public class JobChainingListener : IJobListener -{ - private readonly string _firstJobName; - private readonly string _nextJobName; - - public JobChainingListener(string firstJobName, string nextJobName) - { - _firstJobName = firstJobName; - _nextJobName = nextJobName; - } - - public string Name => nameof(JobChainingListener); - - public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask; - - public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask; - - public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(_nextJobName) || context.JobDetail.Key.Name == _nextJobName || context.JobDetail.Key.Name != _firstJobName) - { - return; - } - - IScheduler scheduler = context.Scheduler; - JobKey nextJobKey = new(_nextJobName); - - if (await scheduler.CheckExists(nextJobKey, cancellationToken)) - { - await scheduler.TriggerJob(nextJobKey, cancellationToken); - } - } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs index 883ba0a0..cfb85b9a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs @@ -1,7 +1,6 @@ using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Events; -using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; @@ -21,18 +20,18 @@ namespace Cleanuparr.Infrastructure.Features.Jobs; public sealed class MalwareBlocker : GenericHandler { - private readonly BlocklistProvider _blocklistProvider; + private readonly IBlocklistProvider _blocklistProvider; public MalwareBlocker( ILogger logger, DataContext dataContext, IMemoryCache cache, IBus messageBus, - ArrClientFactory arrClientFactory, - ArrQueueIterator arrArrQueueIterator, - DownloadServiceFactory downloadServiceFactory, - BlocklistProvider blocklistProvider, - EventPublisher eventPublisher + IArrClientFactory arrClientFactory, + IArrQueueIterator arrArrQueueIterator, + IDownloadServiceFactory downloadServiceFactory, + IBlocklistProvider blocklistProvider, + IEventPublisher eventPublisher ) : base( logger, dataContext, cache, messageBus, arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs index e82a199c..5c7b334e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs @@ -1,7 +1,6 @@ using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Events; -using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; @@ -26,10 +25,10 @@ public sealed class QueueCleaner : GenericHandler DataContext dataContext, IMemoryCache cache, IBus messageBus, - ArrClientFactory arrClientFactory, - ArrQueueIterator arrArrQueueIterator, - DownloadServiceFactory downloadServiceFactory, - EventPublisher eventPublisher + IArrClientFactory arrClientFactory, + IArrQueueIterator arrArrQueueIterator, + IDownloadServiceFactory downloadServiceFactory, + IEventPublisher eventPublisher ) : base( logger, dataContext, cache, messageBus, arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher @@ -124,7 +123,7 @@ public sealed class QueueCleaner : GenericHandler if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase)) { - _logger.LogInformation("skip | {title} | ignored", record.Title); + _logger.LogInformation("skip | download is ignored | {name}", record.Title); continue; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/BlocklistProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/BlocklistProvider.cs index 173c156f..2095d4f7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/BlocklistProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/BlocklistProvider.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.MalwareBlocker; -public sealed class BlocklistProvider +public sealed class BlocklistProvider : IBlocklistProvider { private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/IBlocklistProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/IBlocklistProvider.cs new file mode 100644 index 00000000..5c21fea0 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/IBlocklistProvider.cs @@ -0,0 +1,18 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Infrastructure.Features.MalwareBlocker; + +public interface IBlocklistProvider +{ + Task LoadBlocklistsAsync(); + + BlocklistType GetBlocklistType(InstanceType instanceType); + + ConcurrentBag GetPatterns(InstanceType instanceType); + + ConcurrentBag GetRegexes(InstanceType instanceType); + + ConcurrentBag GetMalwarePatterns(); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Consumers/NotificationConsumer.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Consumers/NotificationConsumer.cs index 6e213d18..3f6aaa3e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Consumers/NotificationConsumer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Consumers/NotificationConsumer.cs @@ -9,11 +9,13 @@ public sealed class NotificationConsumer : IConsumer where T : Notificatio { private readonly ILogger> _logger; private readonly NotificationService _notificationService; + private readonly TimeProvider _timeProvider; - public NotificationConsumer(ILogger> logger, NotificationService notificationService) + public NotificationConsumer(ILogger> logger, NotificationService notificationService, TimeProvider timeProvider) { _logger = logger; _notificationService = notificationService; + _timeProvider = timeProvider; } public async Task Consume(ConsumeContext context) @@ -62,7 +64,7 @@ public sealed class NotificationConsumer : IConsumer where T : Notificatio } // prevent spamming - await Task.Delay(1000); + await Task.Delay(TimeSpan.FromSeconds(1), _timeProvider); } catch (Exception exception) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Security/AesEncryptionService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Security/AesEncryptionService.cs deleted file mode 100644 index e66fe23a..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Security/AesEncryptionService.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Cleanuparr.Persistence; -using Microsoft.Extensions.Logging; - -namespace Cleanuparr.Infrastructure.Features.Security; - -/// -/// Provides AES-128 GCM encryption services for sensitive data using the application's encryption key. -/// -public class AesEncryptionService : IEncryptionService -{ - private readonly ILogger _logger; - private readonly byte[] _key; - private readonly byte[] _nonce; - private const string EncryptedPrefix = "AES128GCM:"; - - public AesEncryptionService(ILogger logger, DataContext dataContext) - { - _logger = logger; - - var generalConfig = dataContext.GeneralConfigs.First(); - - // Derive key and nonce from the GUID string - var keyBytes = Encoding.UTF8.GetBytes(generalConfig.EncryptionKey); - - // Create a 16-byte key for AES-128 - _key = new byte[16]; - Buffer.BlockCopy(keyBytes, 0, _key, 0, Math.Min(keyBytes.Length, 16)); - - // Use the remaining bytes for the nonce (or use a fixed portion if needed) - _nonce = new byte[12]; // 12 bytes for GCM nonce - - // If the key is longer than 16 bytes, use the additional bytes for the nonce - if (keyBytes.Length > 16) - { - Buffer.BlockCopy(keyBytes, 16, _nonce, 0, Math.Min(keyBytes.Length - 16, 12)); - } - else - { - // Use a different portion of the key for the nonce - for (int i = 0; i < Math.Min(keyBytes.Length, 12); i++) - { - _nonce[i] = keyBytes[keyBytes.Length - i - 1]; - } - } - - _logger.LogDebug("Encryption service initialized"); - } - - /// - public string Encrypt(string plainText) - { - if (string.IsNullOrEmpty(plainText)) - { - return plainText; - } - - try - { - byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); - byte[] cipherBytes; - byte[] tag = new byte[16]; // GCM authentication tag - - using (var aes = new AesGcm(_key)) - { - cipherBytes = new byte[plainBytes.Length]; - aes.Encrypt(_nonce, plainBytes, cipherBytes, tag); - } - - // Combine nonce, ciphertext, and tag - byte[] result = new byte[cipherBytes.Length + tag.Length]; - Buffer.BlockCopy(cipherBytes, 0, result, 0, cipherBytes.Length); - Buffer.BlockCopy(tag, 0, result, cipherBytes.Length, tag.Length); - - // Convert to Base64 and add prefix - return $"{EncryptedPrefix}{Convert.ToBase64String(result)}"; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to encrypt value"); - throw; - } - } - - /// - public string Decrypt(string cipherText) - { - if (string.IsNullOrEmpty(cipherText) || !IsEncrypted(cipherText)) - { - return cipherText; - } - - try - { - // Remove prefix and decode Base64 - string base64 = cipherText.Substring(EncryptedPrefix.Length); - byte[] encryptedData = Convert.FromBase64String(base64); - - // Extract ciphertext and tag - int cipherLength = encryptedData.Length - 16; // Last 16 bytes are the tag - byte[] cipherBytes = new byte[cipherLength]; - byte[] tag = new byte[16]; - - Buffer.BlockCopy(encryptedData, 0, cipherBytes, 0, cipherLength); - Buffer.BlockCopy(encryptedData, cipherLength, tag, 0, 16); - - // Decrypt - byte[] plainBytes = new byte[cipherLength]; - using (var aes = new AesGcm(_key)) - { - aes.Decrypt(_nonce, cipherBytes, tag, plainBytes); - } - - return Encoding.UTF8.GetString(plainBytes); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to decrypt value: {CipherText}", cipherText); - throw; // As per requirements, we throw an exception on decryption failure - } - } - - /// - public bool IsEncrypted(string text) - { - return !string.IsNullOrEmpty(text) && text.StartsWith(EncryptedPrefix); - } -} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Security/IEncryptionService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Security/IEncryptionService.cs deleted file mode 100644 index d133b0cc..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Security/IEncryptionService.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Security; - -/// -/// Provides encryption and decryption services for sensitive data. -/// -public interface IEncryptionService -{ - /// - /// Encrypts a plain text string. - /// - /// The text to encrypt. - /// The encrypted string. - string Encrypt(string plainText); - - /// - /// Decrypts an encrypted string. - /// - /// The encrypted text to decrypt. - /// The decrypted plain text string. - /// Thrown when decryption fails. - string Decrypt(string cipherText); - - /// - /// Checks if a string is in encrypted format. - /// - /// The text to check. - /// True if the text appears to be encrypted, false otherwise. - bool IsEncrypted(string text); -} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Security/SensitiveDataJsonConverter.cs b/code/backend/Cleanuparr.Infrastructure/Features/Security/SensitiveDataJsonConverter.cs deleted file mode 100644 index 81cc94ea..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Security/SensitiveDataJsonConverter.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; - -namespace Cleanuparr.Infrastructure.Features.Security; - -/// -/// Handles encryption and decryption of sensitive data during JSON serialization and deserialization. -/// -public class SensitiveDataJsonConverter : JsonConverter -{ - private readonly ILogger _logger; - private readonly IEncryptionService _encryptionService; - - /// - /// Initializes a new instance of the class. - /// - /// The encryption service to use. - /// The logger instance. - public SensitiveDataJsonConverter(ILogger logger, IEncryptionService encryptionService) - { - _logger = logger; - _encryptionService = encryptionService; - } - - /// - public override bool CanConvert(Type typeToConvert) - { - // Only apply to string properties marked with the SensitiveData attribute - return typeToConvert == typeof(string); - } - - /// - public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - string? value = reader.GetString(); - - if (string.IsNullOrEmpty(value)) - { - return value; - } - - try - { - // If the value is encrypted, decrypt it - if (_encryptionService.IsEncrypted(value)) - { - return _encryptionService.Decrypt(value); - } - - return value; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to decrypt sensitive value during JSON deserialization"); - throw; // As per requirements, we throw an exception on decryption failure - } - } - - /// - public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) - { - if (string.IsNullOrEmpty(value)) - { - writer.WriteStringValue(value); - return; - } - - try - { - // Don't re-encrypt already encrypted values - if (_encryptionService.IsEncrypted(value)) - { - writer.WriteStringValue(value); - return; - } - - // Encrypt the value - string encrypted = _encryptionService.Encrypt(value); - writer.WriteStringValue(encrypted); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to encrypt sensitive value during JSON serialization"); - throw; - } - } -} diff --git a/code/backend/Cleanuparr.Infrastructure/Health/HealthCheckService.cs b/code/backend/Cleanuparr.Infrastructure/Health/HealthCheckService.cs index 61aff583..1c6b2f55 100644 --- a/code/backend/Cleanuparr.Infrastructure/Health/HealthCheckService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Health/HealthCheckService.cs @@ -61,7 +61,7 @@ public class HealthCheckService : IHealthCheckService } // Get the client instance - var downloadServiceFactory = scope.ServiceProvider.GetRequiredService(); + var downloadServiceFactory = scope.ServiceProvider.GetRequiredService(); var client = downloadServiceFactory.GetDownloadService(downloadClientConfig); // Execute the health check diff --git a/code/backend/Cleanuparr.Infrastructure/Properties/AssemblyInfo.cs b/code/backend/Cleanuparr.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..f153dc6a --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Cleanuparr.Infrastructure.Tests")] diff --git a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IRuleEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IRuleEvaluator.cs index 7c5871af..27374a50 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IRuleEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IRuleEvaluator.cs @@ -6,6 +6,6 @@ namespace Cleanuparr.Infrastructure.Services.Interfaces; public interface IRuleEvaluator { - Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateStallRulesAsync(ITorrentItem torrent); - Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateSlowRulesAsync(ITorrentItem torrent); + Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent); + Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateSlowRulesAsync(ITorrentItemWrapper torrent); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IRuleManager.cs b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IRuleManager.cs index 4452cb20..40b1cc7c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IRuleManager.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IRuleManager.cs @@ -5,6 +5,6 @@ namespace Cleanuparr.Infrastructure.Services.Interfaces; public interface IRuleManager { - StallRule? GetMatchingStallRule(ITorrentItem torrent); - SlowRule? GetMatchingSlowRule(ITorrentItem torrent); + StallRule? GetMatchingStallRule(ITorrentItemWrapper torrent); + SlowRule? GetMatchingSlowRule(ITorrentItemWrapper torrent); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Services/RuleEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Services/RuleEvaluator.cs index 1a2a1066..2926dbd0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/RuleEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/RuleEvaluator.cs @@ -35,7 +35,7 @@ public class RuleEvaluator : IRuleEvaluator .SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer); } - public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateStallRulesAsync(ITorrentItem torrent) + public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent) { _logger.LogTrace("Evaluating stall rules | {name}", torrent.Name); @@ -73,7 +73,7 @@ public class RuleEvaluator : IRuleEvaluator return (false, DeleteReason.None, false); } - public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateSlowRulesAsync(ITorrentItem torrent) + public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateSlowRulesAsync(ITorrentItemWrapper torrent) { _logger.LogTrace("Evaluating slow rules | {name}", torrent.Name); @@ -143,7 +143,7 @@ public class RuleEvaluator : IRuleEvaluator } private async Task ResetStalledStrikesAsync( - ITorrentItem torrent, + ITorrentItemWrapper torrent, bool resetEnabled, long? minimumProgressBytes ) @@ -195,7 +195,7 @@ public class RuleEvaluator : IRuleEvaluator } private async Task ResetSlowStrikesAsync( - ITorrentItem torrent, + ITorrentItemWrapper torrent, bool resetEnabled, StrikeType strikeType ) @@ -208,7 +208,7 @@ public class RuleEvaluator : IRuleEvaluator await _striker.ResetStrikeAsync(torrent.Hash, torrent.Name, strikeType); } - private bool HasStalledDownloadProgress(ITorrentItem torrent, StrikeType strikeType, out long previousDownloaded, out long currentDownloaded) + private bool HasStalledDownloadProgress(ITorrentItemWrapper torrent, StrikeType strikeType, out long previousDownloaded, out long currentDownloaded) { previousDownloaded = 0; currentDownloaded = Math.Max(0, torrent.DownloadedBytes); diff --git a/code/backend/Cleanuparr.Infrastructure/Services/RuleManager.cs b/code/backend/Cleanuparr.Infrastructure/Services/RuleManager.cs index 2083e3b5..6929254b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/RuleManager.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/RuleManager.cs @@ -15,19 +15,19 @@ public class RuleManager : IRuleManager _logger = logger; } - public StallRule? GetMatchingStallRule(ITorrentItem torrent) + public StallRule? GetMatchingStallRule(ITorrentItemWrapper torrent) { var stallRules = ContextProvider.Get>(nameof(StallRule)); return GetMatchingQueueRule(torrent, stallRules); } - public SlowRule? GetMatchingSlowRule(ITorrentItem torrent) + public SlowRule? GetMatchingSlowRule(ITorrentItemWrapper torrent) { var slowRules = ContextProvider.Get>(nameof(SlowRule)); return GetMatchingQueueRule(torrent, slowRules); } - private TRule? GetMatchingQueueRule(ITorrentItem torrent, IReadOnlyList rules) where TRule : QueueRule + private TRule? GetMatchingQueueRule(ITorrentItemWrapper torrent, IReadOnlyList rules) where TRule : QueueRule { if (rules.Count is 0) { diff --git a/code/backend/Cleanuparr.Infrastructure/Utilities/CronExpressionConverter.cs b/code/backend/Cleanuparr.Infrastructure/Utilities/CronExpressionConverter.cs index 11ecb662..752c951d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Utilities/CronExpressionConverter.cs +++ b/code/backend/Cleanuparr.Infrastructure/Utilities/CronExpressionConverter.cs @@ -51,84 +51,10 @@ public static class CronExpressionConverter public static bool IsValidCronExpression(string cronExpression) { if (string.IsNullOrWhiteSpace(cronExpression)) - return false; - - try - { - return CronExpression.IsValidExpression(cronExpression); - } - catch { return false; } - } - - /// - /// Try to get a user-friendly description of a cron expression - /// - /// The cron expression to describe - /// A human-readable description or null if not valid - public static string? GetCronDescription(string cronExpression) - { - if (!IsValidCronExpression(cronExpression)) - return null; - try - { - var expression = new CronExpression(cronExpression); - // This is a simplified description - a proper implementation would use - // a library like CronExpressionDescriptor to provide a better description - return $"Custom schedule: {cronExpression}"; - } - catch - { - return null; - } - } - - /// - /// This method is only kept for reference. We no longer parse schedules from strings. - /// - /// The schedule string to parse - /// A JobSchedule object if successful, null otherwise - [Obsolete("Schedule should be provided as a proper object, not a string.")] - private static JobSchedule? TryParseSchedule(string scheduleString) - { - if (string.IsNullOrEmpty(scheduleString)) - return null; - - try - { - // Expecting format like "every: 30, type: minutes" - var parts = scheduleString.Split(',', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length != 2) - return null; - - var intervalPart = parts[0].Trim(); - var typePart = parts[1].Trim(); - - // Extract interval value - var intervalValue = intervalPart.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (intervalValue.Length != 2 || !intervalValue[0].Trim().Equals("every", StringComparison.OrdinalIgnoreCase)) - return null; - - if (!int.TryParse(intervalValue[1].Trim(), out var interval) || interval <= 0) - return null; - - // Extract unit type - var typeParts = typePart.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (typeParts.Length != 2 || !typeParts[0].Trim().Equals("type", StringComparison.OrdinalIgnoreCase)) - return null; - - var unitString = typeParts[1].Trim(); - if (!Enum.TryParse(unitString, true, out var unit)) - return null; - - return new JobSchedule { Every = interval, Type = unit }; - } - catch - { - return null; - } + return CronExpression.IsValidExpression(cronExpression); } } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/IQueueRule.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/IQueueRule.cs index a4312f2e..2e85f3ff 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/IQueueRule.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/IQueueRule.cs @@ -19,7 +19,7 @@ public interface IQueueRule ushort MaxCompletionPercentage { get; } - bool MatchesTorrent(ITorrentItem torrent); + bool MatchesTorrent(ITorrentItemWrapper torrent); void Validate(); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/QueueRule.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/QueueRule.cs index 6e6b46da..0b184e9f 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/QueueRule.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/QueueRule.cs @@ -29,7 +29,7 @@ public abstract record QueueRule : IConfig, IQueueRule public bool DeletePrivateTorrentsFromClient { get; init; } = false; - public abstract bool MatchesTorrent(ITorrentItem torrent); + public abstract bool MatchesTorrent(ITorrentItemWrapper torrent); public virtual void Validate() { diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/SlowRule.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/SlowRule.cs index 0908b82f..65a94adc 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/SlowRule.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/SlowRule.cs @@ -19,7 +19,7 @@ public sealed record SlowRule : QueueRule [JsonIgnore] public ByteSize? IgnoreAboveSizeByteSize => string.IsNullOrEmpty(IgnoreAboveSize) ? null : ByteSize.Parse(IgnoreAboveSize); - public override bool MatchesTorrent(ITorrentItem torrent) + public override bool MatchesTorrent(ITorrentItemWrapper torrent) { // Check privacy type if (!MatchesPrivacyType(torrent.IsPrivate)) diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/StallRule.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/StallRule.cs index 6fa71dc3..2d30640a 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/StallRule.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/StallRule.cs @@ -14,7 +14,7 @@ public sealed record StallRule : QueueRule ? null : ByteSize.Parse(MinimumProgress); - public override bool MatchesTorrent(ITorrentItem torrent) + public override bool MatchesTorrent(ITorrentItemWrapper torrent) { // Check privacy type if (!MatchesPrivacyType(torrent.IsPrivate))