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.Jobs; using Cleanuparr.Infrastructure.Features.MalwareBlocker; using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; 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 ILogger _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, _fixture.DataContext, _fixture.Cache, _fixture.MessageBus, _fixture.ArrClientFactory, _fixture.ArrQueueIterator, _fixture.DownloadServiceFactory, _fixture.BlocklistProvider, _fixture.EventPublisher, _fixture.JobManagementService ); } #region ExecuteInternalAsync Tests [Fact] public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns() { // Arrange var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert _logger.ReceivedLogContaining(LogLevel.Warning, "No download clients configured"); } [Fact] public async Task ExecuteInternalAsync_WhenNoBlocklistsEnabled_LogsWarningAndReturns() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert _logger.ReceivedLogContaining(LogLevel.Warning, "No blocklists are enabled"); } [Fact] public async Task ExecuteInternalAsync_WhenBlocklistEnabled_LoadsBlocklists() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); _fixture.ArrClientFactory .GetClient(Arg.Any(), Arg.Any()) .Returns(mockArrClient); _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(Task.CompletedTask); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert await _fixture.BlocklistProvider.Received(1).LoadBlocklistsAsync(); } [Fact] public async Task ExecuteInternalAsync_WhenSonarrEnabled_ProcessesSonarrInstances() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(Task.CompletedTask); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert _fixture.ArrClientFactory.Received(1).GetClient(InstanceType.Sonarr, Arg.Any()); } [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 = Substitute.For(); _fixture.ArrClientFactory .GetClient(instanceType, Arg.Any()) .Returns(mockArrClient); _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(Task.CompletedTask); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert _fixture.ArrClientFactory.Received(1).GetClient(instanceType, Arg.Any()); } #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 = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var queueRecord = new QueueRecord { Id = 1, DownloadId = "ignored-download-id", Title = "Ignored Download", Protocol = "torrent", SeriesId = 1, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert _logger.ReceivedLogContaining(LogLevel.Information, "ignored"); } [Fact] public async Task ProcessInstanceAsync_ChecksTorrentClientsForBlockedFiles() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var queueRecord = new QueueRecord { Id = 1, DownloadId = "torrent-download-id", Title = "Torrent Download", Protocol = "torrent", SeriesId = 1, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync( Arg.Any(), Arg.Any>() ) .Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert await mockDownloadService.Received(1) .BlockUnwantedFilesAsync("torrent-download-id", Arg.Any>()); } [Fact] public async Task ProcessInstanceAsync_WhenShouldRemove_PublishesRemoveRequest() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var queueRecord = new QueueRecord { Id = 1, DownloadId = "malware-download-id", Title = "Malware Download", Protocol = "torrent", SeriesId = 1, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync( Arg.Any(), Arg.Any>() ) .Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = true, IsPrivate = false, DeleteReason = DeleteReason.AllFilesBlocked }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert await _fixture.MessageBus.Received(1).Publish( Arg.Is>(r => r.DeleteReason == DeleteReason.AllFilesBlocked ), Arg.Any() ); } [Fact] public async Task ExecuteInternalAsync_WhenWebhookTarget_ScansOnlyMatchingDownload() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var matching = new QueueRecord { Id = 1, DownloadId = "match-hash", Title = "Match", Protocol = "torrent", SeriesId = 5, EpisodeId = 1 }; var other = new QueueRecord { Id = 2, DownloadId = "other-hash", Title = "Other", Protocol = "torrent", SeriesId = 5, EpisodeId = 2 }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>(), Arg.Any() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([matching, other]); }); var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()) .Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set( new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget( sonarrInstance.Id, "match-hash", 5, InstanceType.Sonarr)); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert await mockDownloadService.Received(1).BlockUnwantedFilesAsync("match-hash", Arg.Any>()); await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync("other-hash", Arg.Any>()); // Found in a client -> resolved, no retry scheduled await _fixture.JobManagementService.DidNotReceive() .ScheduleMalwareBlockerWebhookRetry(Arg.Any()); } [Fact] public async Task ExecuteInternalAsync_WhenWebhookTargetNotFoundInClient_SchedulesRetry() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var record = new QueueRecord { Id = 1, DownloadId = "pending-hash", Title = "Pending", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate(Arg.Any(), Arg.Any(), Arg.Any, Task>>(), Arg.Any()) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([record]); }); // Torrent not (yet) present in any client var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()) .Returns(new BlockFilesResult { Found = false }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set( new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget( sonarrInstance.Id, "pending-hash", 7, InstanceType.Sonarr, RetryIndex: 1)); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry( Arg.Is(t => t.InstanceId == sonarrInstance.Id && t.DownloadId == "pending-hash" && t.ContentId == 7 && t.Type == InstanceType.Sonarr && t.RetryIndex == 1)); } [Fact] public async Task ExecuteInternalAsync_WhenWebhookTargetMetadataMissing_SchedulesRetry() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var record = new QueueRecord { Id = 1, DownloadId = "metadl-hash", Title = "MetaDL", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate(Arg.Any(), Arg.Any(), Arg.Any, Task>>(), Arg.Any()) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([record]); }); // Torrent found in the client, but its metadata/file list is not ready yet var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()) .Returns(new BlockFilesResult { Found = true }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set( new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget( sonarrInstance.Id, "metadl-hash", 7, InstanceType.Sonarr, RetryIndex: 0)); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry( Arg.Is(t => t.DownloadId == "metadl-hash" && t.RetryIndex == 0)); } [Fact] public async Task ExecuteInternalAsync_WhenWebhookTargetIsUsenet_DoesNotScanOrRetry() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var record = new QueueRecord { Id = 1, DownloadId = "usenet-id", Title = "Usenet", Protocol = "usenet", SeriesId = 9, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate(Arg.Any(), Arg.Any(), Arg.Any, Task>>(), Arg.Any()) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([record]); }); var mockDownloadService = _fixture.CreateMockDownloadService(); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set( new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget( sonarrInstance.Id, "usenet-id", 9, InstanceType.Sonarr)); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert: usenet is acknowledged once seen in the queue -> no scan, no retry await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()); await _fixture.JobManagementService.DidNotReceive() .ScheduleMalwareBlockerWebhookRetry(Arg.Any()); } [Fact] public async Task ProcessInstanceAsync_WhenShouldRemoveWithAtLeastOneFileBlocked_PublishesRemoveRequest() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); IArrClient mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); QueueRecord queueRecord = new() { Id = 1, DownloadId = "any-file-blocked-download-id", Title = "Mixed Malware Download", Protocol = "torrent", SeriesId = 1, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync( Arg.Any(), Arg.Any>() ) .Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = true, IsPrivate = false, DeleteReason = DeleteReason.AtLeastOneFileBlocked }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); MalwareBlockerJob sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert await _fixture.MessageBus.Received(1).Publish( Arg.Is>(r => r.DeleteReason == DeleteReason.AtLeastOneFileBlocked ), Arg.Any() ); } [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 = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var queueRecord = new QueueRecord { Id = 1, DownloadId = "private-malware-id", Title = "Private Malware", Protocol = "torrent", SeriesId = 1, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync( Arg.Any(), Arg.Any>() ) .Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = true, IsPrivate = true, DeleteReason = DeleteReason.AllFilesBlocked }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert - RemoveFromClient should be false await _fixture.MessageBus.Received(1).Publish( Arg.Is>(r => r.RemoveFromClient == false ), Arg.Any() ); } [Fact] public async Task ProcessInstanceAsync_WhenDownloadNotFoundInTorrentClient_LogsWarning() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var queueRecord = new QueueRecord { Id = 1, DownloadId = "missing-download-id", Title = "Missing Download", Protocol = "torrent", SeriesId = 1, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync( Arg.Any(), Arg.Any>() ) .Returns(new BlockFilesResult { Found = false }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert _logger.ReceivedLogContaining(LogLevel.Warning, "Download not found in any torrent client"); } [Fact] public async Task ProcessInstanceAsync_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(false); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var queueRecord = new QueueRecord { Id = 1, DownloadId = "no-content-id-download", Title = "No Content ID Download", Protocol = "torrent" }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert _logger.ReceivedLogContaining(LogLevel.Information, "skip | item is missing the content id"); await _fixture.MessageBus.DidNotReceive().Publish( Arg.Any>(), Arg.Any() ); } [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 = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(false); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var queueRecord = new QueueRecord { Id = 1, DownloadId = "no-content-id-download", Title = "No Content ID Download", Protocol = "torrent" }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync( Arg.Any(), Arg.Any>() ) .Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = true, IsPrivate = false, DeleteReason = DeleteReason.AllFilesBlocked }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert - SkipSearch must be true because the item has no content ID await _fixture.MessageBus.Received(1).Publish( Arg.Is>(r => r.SkipSearch == true && r.DeleteReason == DeleteReason.AllFilesBlocked ), Arg.Any() ); } #endregion #region Error Handling Tests [Fact] public async Task ProcessInstanceAsync_WhenDownloadServiceThrows_LogsErrorAndContinues() { // Arrange TestDataContextFactory.AddDownloadClient(_fixture.DataContext); EnableSonarrBlocklist(); TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockArrClient = Substitute.For(); mockArrClient.IsRecordValid(Arg.Any()).Returns(true); mockArrClient.HasContentId(Arg.Any()).Returns(true); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) .Returns(mockArrClient); var queueRecord = new QueueRecord { Id = 1, DownloadId = "error-download-id", Title = "Error Download", Protocol = "torrent", SeriesId = 1, EpisodeId = 1 }; _fixture.ArrQueueIterator .Iterate( Arg.Any(), Arg.Any(), Arg.Any, Task>>() ) .Returns(ci => { var callback = ci.ArgAt, Task>>(2); return callback([queueRecord]); }); var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync( Arg.Any(), Arg.Any>() ) .ThrowsAsync(new Exception("Connection failed")); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) .Returns(mockDownloadService); var sut = CreateSut(); // Act await sut.ExecuteAsync(); // Assert _logger.ReceivedLogContaining(LogLevel.Error, "Error checking download"); } #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 }