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(), 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, 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, It.IsAny()), Times.Once); } [Theory] [InlineData(InstanceType.Radarr)] [InlineData(InstanceType.Lidarr)] [InlineData(InstanceType.Readarr)] [InlineData(InstanceType.Whisparr)] public async Task ExecuteInternalAsync_WhenArrTypeEnabled_ProcessesCorrectInstances(InstanceType instanceType) { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableBlocklist(instanceType); AddArrInstance(instanceType); var mockArrClient = new Mock(); _fixture.ArrClientFactory .Setup(x => x.GetClient(instanceType, 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, It.IsAny()), 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); mockArrClient.Setup(x => x.HasContentId(It.IsAny())).Returns(true); _fixture.ArrClientFactory .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) .Returns(mockArrClient.Object); var queueRecord = new QueueRecord { Id = 1, DownloadId = "ignored-download-id", Title = "Ignored 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 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); mockArrClient.Setup(x => x.HasContentId(It.IsAny())).Returns(true); _fixture.ArrClientFactory .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) .Returns(mockArrClient.Object); var queueRecord = new QueueRecord { Id = 1, DownloadId = "torrent-download-id", Title = "Torrent 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 = 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); mockArrClient.Setup(x => x.HasContentId(It.IsAny())).Returns(true); _fixture.ArrClientFactory .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) .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); mockArrClient.Setup(x => x.HasContentId(It.IsAny())).Returns(true); _fixture.ArrClientFactory .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) .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); mockArrClient.Setup(x => x.HasContentId(It.IsAny())).Returns(true); _fixture.ArrClientFactory .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) .Returns(mockArrClient.Object); var queueRecord = new QueueRecord { Id = 1, DownloadId = "missing-download-id", Title = "Missing 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 = 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_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = new Mock(); mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); mockArrClient.Setup(x => x.HasContentId(It.IsAny())).Returns(false); _fixture.ArrClientFactory .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) .Returns(mockArrClient.Object); var queueRecord = new QueueRecord { Id = 1, DownloadId = "no-content-id-download", Title = "No Content ID 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("skip | item is missing the content id")), It.IsAny(), It.IsAny>() ), Times.Once ); _fixture.MessageBus.Verify( x => x.Publish( It.IsAny>(), It.IsAny() ), Times.Never ); } [Fact] public async Task ProcessInstanceAsync_WhenMissingContentId_AndProcessNoContentIdIsTrue_PublishesRemoveRequestWithSkipSearch() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First(); contentBlockerConfig.ProcessNoContentId = true; _fixture.DataContext.SaveChanges(); var mockArrClient = new Mock(); mockArrClient.Setup(x => x.IsRecordValid(It.IsAny())).Returns(true); mockArrClient.Setup(x => x.HasContentId(It.IsAny())).Returns(false); _fixture.ArrClientFactory .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) .Returns(mockArrClient.Object); var queueRecord = new QueueRecord { Id = 1, DownloadId = "no-content-id-download", Title = "No Content ID 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 = 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 - SkipSearch must be true because the item has no content ID _fixture.MessageBus.Verify( x => x.Publish( It.Is>(r => r.SkipSearch == true && r.DeleteReason == DeleteReason.AllFilesBlocked ), 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); mockArrClient.Setup(x => x.HasContentId(It.IsAny())).Returns(true); _fixture.ArrClientFactory .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) .Returns(mockArrClient.Object); var queueRecord = new QueueRecord { Id = 1, DownloadId = "error-download-id", Title = "Error 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>() )) .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(); } private void EnableBlocklist(InstanceType instanceType) { var config = _fixture.DataContext.ContentBlockerConfigs.First(); var settings = new BlocklistSettings { Enabled = true }; switch (instanceType) { case InstanceType.Radarr: config.Radarr = settings; break; case InstanceType.Lidarr: config.Lidarr = settings; break; case InstanceType.Readarr: config.Readarr = settings; break; case InstanceType.Whisparr: config.Whisparr = settings; break; default: throw new ArgumentOutOfRangeException(nameof(instanceType)); } _fixture.DataContext.SaveChanges(); } private void AddArrInstance(InstanceType instanceType) { switch (instanceType) { case InstanceType.Radarr: TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); break; case InstanceType.Lidarr: TestDataContextFactory.AddLidarrInstance(_fixture.DataContext); break; case InstanceType.Readarr: TestDataContextFactory.AddReadarrInstance(_fixture.DataContext); break; case InstanceType.Whisparr: TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext); break; default: throw new ArgumentOutOfRangeException(nameof(instanceType)); } } #endregion }