Files
Cleanuparr/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs
T
2026-10-11 20:33:42 +03:00

970 lines
34 KiB
C#

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.LazyLibrarian;
using Cleanuparr.Domain.Entities.LazyLibrarian;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
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.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
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<MalwareBlockerJob> _logger;
public MalwareBlockerTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = _fixture.CreateLogger<MalwareBlockerJob>();
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
private MalwareBlockerJob CreateSut()
{
return new MalwareBlockerJob(
_logger,
_fixture.DataContext,
_fixture.Cache,
_fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
_fixture.BlocklistProvider,
_fixture.EventPublisher,
_fixture.DryRunInterceptor,
_fixture.ForceImportService,
_fixture.LazyLibrarianServiceCB
);
}
#region ExecuteInternalAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
{
// Arrange
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.HasLogContaining(LogLevel.Warning, "No download clients configured").ShouldBeTrue();
}
[Fact]
public async Task ExecuteInternalAsync_WhenNoBlocklistsEnabled_LogsWarningAndReturns()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.HasLogContaining(LogLevel.Warning, "No blocklists are enabled").ShouldBeTrue();
}
[Fact]
public async Task ExecuteInternalAsync_WhenBlocklistEnabled_LoadsBlocklists()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
_fixture.ArrClientFactory
.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>())
.Returns(mockArrClient);
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([]);
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<IArrClient>();
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_fixture.ArrClientFactory.Received(1).GetClient(InstanceType.Sonarr, Arg.Any<float>());
}
[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<IArrClient>();
_fixture.ArrClientFactory
.GetClient(instanceType, Arg.Any<float>())
.Returns(mockArrClient);
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_fixture.ArrClientFactory.Received(1).GetClient(instanceType, Arg.Any<float>());
}
#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<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "ignored-download-id",
Title = "Ignored Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.HasLogContaining(LogLevel.Information, "ignored").ShouldBeTrue();
}
[Fact]
public async Task ProcessInstanceAsync_SkipsDownloadsIgnoredByClientName()
{
// Arrange
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
generalConfig.IgnoredDownloads = ["myDownloadClient"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "not-a-valid-hash",
DownloadClient = "MYDOWNLOADCLIENT",
Title = "Ignored By Client",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.HasLogContaining(LogLevel.Information, "ignored").ShouldBeTrue();
}
[Fact]
public async Task ProcessInstanceAsync_ChecksTorrentClientsForBlockedFiles()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "torrent-download-id",
Title = "Torrent Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult { Found = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await mockDownloadService.Received(1)
.BlockUnwantedFilesAsync("torrent-download-id", Arg.Any<List<string>>());
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemove_PublishesRemoveRequest()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "malware-download-id",
Title = "Malware Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
r.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemove_MarksDownloadInCache()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "malware-download-id",
Title = "Malware Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
var cacheKey = CacheKeys.DownloadMarkedForRemoval("malware-download-id", sonarrInstance.Url);
_fixture.Cache.TryGetValue(cacheKey, out bool marked).ShouldBeTrue();
marked.ShouldBeTrue();
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemoveWithAtLeastOneFileBlocked_PublishesRemoveRequest()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
IArrClient mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
QueueRecord queueRecord = new()
{
Id = 1,
DownloadId = "any-file-blocked-download-id",
Title = "Mixed Malware Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AtLeastOneFileBlocked
});
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
MalwareBlockerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
r.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
}
[Fact]
public async Task ProcessInstanceAsync_WhenPackRecordsAreNotAdjacent_ChecksBlockUnwantedFilesOnlyOnce()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
IArrClient mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
QueueRecord packEp2 = new() { Id = 1, DownloadId = "pack-download-id", Title = "Pack S01E02", Protocol = "torrent", SeriesId = 1, EpisodeId = 2 };
QueueRecord otherDownload = new() { Id = 2, DownloadId = "other-download-id", Title = "Other Download", Protocol = "torrent", SeriesId = 1, EpisodeId = 20 };
QueueRecord packEp3 = new() { Id = 3, DownloadId = "pack-download-id", Title = "Pack S01E03", Protocol = "torrent", SeriesId = 1, EpisodeId = 3 };
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([packEp2, otherDownload, packEp3]);
IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
MalwareBlockerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await mockDownloadService.Received(1)
.BlockUnwantedFilesAsync("pack-download-id", Arg.Any<List<string>>());
}
[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<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "private-malware-id",
Title = "Private Malware",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = true,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - RemoveFromClient should be false
QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
r.ArrTarget().RemoveFromClient.ShouldBeFalse();
}
[Fact]
public async Task ProcessInstanceAsync_WhenDownloadNotFoundInTorrentClient_LogsWarning()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "missing-download-id",
Title = "Missing Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult { Found = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.HasLogContaining(LogLevel.Warning, "Download not found in any torrent client").ShouldBeTrue();
}
[Fact]
public async Task ProcessInstanceAsync_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(false);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "no-content-id-download",
Title = "No Content ID Download",
Protocol = "torrent"
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.HasLogContaining(LogLevel.Information, "skip | item is missing the content id").ShouldBeTrue();
_fixture.RemovalQueue.Reader.Count.ShouldBe(0);
}
[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<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(false);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "no-content-id-download",
Title = "No Content ID Download",
Protocol = "torrent"
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - SkipSearch must be true because the item has no content ID
QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
r.SkipSearch.ShouldBeTrue();
r.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
#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<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "error-download-id",
Title = "Error Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.ThrowsAsync(new Exception("Connection failed"));
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.HasLogContaining(LogLevel.Error, "Error checking download").ShouldBeTrue();
}
[Fact]
public async Task ProcessInstanceAsync_WhenFirstClientThrows_NextClientStillFindsTheDownload()
{
// Arrange
DownloadClientConfig configA = TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "Client A");
DownloadClientConfig configB = TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "Client B");
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "two-clients-download-id",
Title = "Two Clients Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueReader
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
// Client load order is not fixed, so whichever client runs first throws.
int calls = 0;
Func<NSubstitute.Core.CallInfo, Task<BlockFilesResult>> firstThrows = _ => calls++ == 0
? throw new Exception("Connection failed")
: Task.FromResult(new BlockFilesResult { Found = true, ShouldRemove = true, DeleteReason = DeleteReason.AllFilesBlocked });
var serviceA = _fixture.CreateMockDownloadService("Client A");
serviceA.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>()).Returns(firstThrows);
var serviceB = _fixture.CreateMockDownloadService("Client B");
serviceB.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>()).Returns(firstThrows);
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Is<DownloadClientConfig>(x => x.Id == configA.Id))
.Returns(serviceA);
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Is<DownloadClientConfig>(x => x.Id == configB.Id))
.Returns(serviceB);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.HasLogContaining(LogLevel.Error, "Error checking download").ShouldBeTrue();
QueueItemRemoveRequest request = _fixture.RemovalQueue.ShouldHaveSingle();
request.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
request.DownloadClient.ShouldNotBeNull();
calls.ShouldBe(2);
}
[Fact]
public async Task ProcessInstanceAsync_LazyLibrarian_PublishesTheEvaluatedDecisions()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableBlocklist(InstanceType.LazyLibrarian);
AddArrInstance(InstanceType.LazyLibrarian);
ITorrentItemWrapper torrent = Substitute.For<ITorrentItemWrapper>();
torrent.DownloadId.Returns("torrent-hash");
var downloadService = _fixture.CreateMockDownloadService();
LazyLibrarianRemovalDecision decision = new()
{
Item = new LazyLibrarianQueueItem
{
DownloadId = "torrent-hash",
Title = "Book",
Books = [new LazyLibrarianBookRef { BookId = "OL7353617M", Library = BookLibrary.EBook }],
Source = LazyLibrarianSource.QBittorrent,
Origin = LazyLibrarianOrigin.New,
},
DeleteReason = DeleteReason.AllFilesBlocked,
RemoveFromClient = true,
DownloadClient = downloadService.ClientConfig,
DownloadService = downloadService,
Download = torrent,
};
_fixture.LazyLibrarianServiceCB
.EvaluateAsync(Arg.Any<ArrInstance>(), Arg.Any<IReadOnlyList<IDownloadService>>(), Arg.Any<IReadOnlyList<string>>())
.Returns([decision]);
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(downloadService);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(ci => ((Func<Task>)ci[0])());
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await downloadService.Received(1).DeleteDownload(torrent, true);
QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
r.LazyTarget().RemovedFromClient.ShouldBeTrue();
r.LazyTarget().Item.Books.Single().BookId.ShouldBe("OL7353617M");
r.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
_fixture.ArrClientFactory.DidNotReceiveWithAnyArgs().GetClient(default, default);
await _fixture.ArrQueueReader.DidNotReceiveWithAnyArgs()
.ReadAllAsync(default!, default!);
}
#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;
case InstanceType.LazyLibrarian: config.LazyLibrarian = 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;
case InstanceType.LazyLibrarian: TestDataContextFactory.AddLazyLibrarianInstance(_fixture.DataContext); break;
default: throw new ArgumentOutOfRangeException(nameof(instanceType));
}
}
#endregion
}