using System.Collections.Concurrent; using System.Text.RegularExpressions; using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.HealthCheck; 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; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using NSubstitute; using NSubstitute.ExceptionExtensions; using Newtonsoft.Json.Linq; using QBittorrent.Client; using Shouldly; 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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(Array.Empty()); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.Found.ShouldBeFalse(); result.ShouldRemove.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { ignoredCategory }); // Assert result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeFalse(); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(true) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()) .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.Found.ShouldBeTrue(); result.IsPrivate.ShouldBeTrue(); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()) .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.Found.ShouldBeTrue(); result.IsPrivate.ShouldBeFalse(); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns((TorrentProperties?)null); // Properties not found // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.Found.ShouldBeFalse(); result.ShouldRemove.ShouldBeFalse(); result.IsPrivate.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); result.DeleteFromClient.ShouldBeFalse(); } [Fact] public async Task TorrentListIsNull_ReturnsEmptyResult() { // Arrange const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetTorrentListAsync(Arg.Any()) .Returns((IReadOnlyList?)null); // Act DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.Found.ShouldBeFalse(); result.ShouldRemove.ShouldBeFalse(); } [Fact] public async Task TorrentTrackersNotFound_ReturnsEmptyResult() { // Arrange const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); TorrentInfo torrentInfo = new() { Hash = hash, Name = "Deleted Torrent", State = TorrentState.Downloading }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns((IReadOnlyList?)null); // Act DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.Found.ShouldBeFalse(); result.ShouldRemove.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); await _fixture.ClientWrapper.DidNotReceive().GetTorrentPropertiesAsync(hash); } [Fact] public async Task TorrentWithOnlyPseudoTrackers_IsFound() { // Arrange const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); TorrentInfo torrentInfo = new() { Hash = hash, Name = "Trackerless Torrent", State = TorrentState.Downloading }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(new TorrentTracker[] { new() { Url = "** [DHT] **", TrackerStatus = TorrentTrackerStatus.Working } }); _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(new TorrentProperties { AdditionalData = new Dictionary { { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(false) } } }); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((false, DeleteReason.None, false, false)); // Act DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.Found.ShouldBeTrue(); ((ITorrentItemWrapper)result.Item!).TrackerDomains.ShouldBeEmpty(); } } 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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(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 result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesSkippedByQBit); result.DeleteFromClient.ShouldBeTrue(); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(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 result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesSkipped); result.DeleteFromClient.ShouldBeTrue(); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip }, new TorrentContent { Index = 1, Priority = TorrentContentPriority.Normal } // At least one wanted }); _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()) .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeFalse(); } } 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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.Striker .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any()) .Returns(false); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeFalse(); await _fixture.Striker.Received(1) .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any()); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.Striker .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any()) .Returns(true); // Strike limit exceeded // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.DownloadingMetadata); result.DeleteFromClient.ShouldBeTrue(); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeFalse(); await _fixture.Striker.DidNotReceive() .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } } public class ShouldRemoveFromArrQueueAsync_ErroredScenarios : QBitServiceTests { public ShouldRemoveFromArrQueueAsync_ErroredScenarios(QBitServiceFixture fixture) : base(fixture) { } private void ArrangeTorrent(string hash, TorrentState state, bool isPrivate = false) { TorrentInfo torrentInfo = new() { Hash = hash, Name = "Test Torrent", State = state }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(isPrivate) } } }); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); } [Theory] [InlineData(TorrentState.Error)] [InlineData(TorrentState.MissingFiles)] public async Task Errored_BelowMaxStrikes_ResumesWithoutRemoving(TorrentState state) { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 3 } }); ArrangeTorrent(hash, state); _fixture.Striker .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.Errored, Arg.Any()) .Returns(false); DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeFalse(); await _fixture.Striker.Received(1) .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.Errored, Arg.Any()); await _fixture.ClientWrapper.Received(1) .ResumeAsync(Arg.Is>(hashes => hashes.Contains(hash))); } [Fact] public async Task Errored_ExceedsMaxStrikes_RemovesWithoutResuming() { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 3 } }); ArrangeTorrent(hash, TorrentState.Error); _fixture.Striker .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.Errored, Arg.Any()) .Returns(true); DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Errored); result.DeleteFromClient.ShouldBeFalse(); result.ChangeCategory.ShouldBeFalse(); await _fixture.ClientWrapper.DidNotReceive() .ResumeAsync(Arg.Any>()); } [Fact] public async Task Errored_WithDeletePrivate_DeletesFromClient() { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 3, DeletePrivate = true } }); ArrangeTorrent(hash, TorrentState.Error, isPrivate: true); _fixture.Striker .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.Errored, Arg.Any()) .Returns(true); DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteFromClient.ShouldBeTrue(); } [Fact] public async Task Errored_WithChangeCategory_ChangesCategoryInsteadOfDeleting() { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 3, ChangeCategory = true } }); ArrangeTorrent(hash, TorrentState.Error); _fixture.Striker .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.Errored, Arg.Any()) .Returns(true); DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.ChangeCategory.ShouldBeTrue(); result.DeleteFromClient.ShouldBeFalse(); } [Fact] public async Task Errored_WithIgnorePrivate_SkipsPrivateDownload() { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 3, IgnorePrivate = true } }); ArrangeTorrent(hash, TorrentState.Error, isPrivate: true); DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeFalse(); await _fixture.Striker.DidNotReceive() .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.Errored, Arg.Any()); await _fixture.Striker.DidNotReceive() .ResetStrikeAsync(Arg.Any(), Arg.Any(), StrikeType.Errored); await _fixture.ClientWrapper.DidNotReceive() .ResumeAsync(Arg.Any>()); } [Fact] public async Task Errored_WithIgnorePrivate_StillChecksPublicDownload() { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 3, IgnorePrivate = true } }); ArrangeTorrent(hash, TorrentState.Error); _fixture.Striker .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.Errored, Arg.Any()) .Returns(false); await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); await _fixture.Striker.Received(1) .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.Errored, Arg.Any()); } [Fact] public async Task Errored_WithStrikesDisabled_DoesNothing() { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 0 } }); ArrangeTorrent(hash, TorrentState.Error); DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeFalse(); await _fixture.Striker.DidNotReceive() .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await _fixture.Striker.DidNotReceive() .ResetStrikeAsync(Arg.Any(), Arg.Any(), StrikeType.Errored); await _fixture.ClientWrapper.DidNotReceive() .ResumeAsync(Arg.Any>()); } [Fact] public async Task Errored_ResumeFailure_DoesNotFailTheCheck() { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 3 } }); ArrangeTorrent(hash, TorrentState.Error); _fixture.Striker .StrikeAndCheckLimit(hash, Arg.Any(), (ushort)3, StrikeType.Errored, Arg.Any()) .Returns(false); _fixture.ClientWrapper .ResumeAsync(Arg.Any>()) .Returns(Task.FromException(new InvalidOperationException("client refused"))); DownloadCheckResult result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeFalse(); } [Theory] [InlineData(TorrentState.Downloading)] [InlineData(TorrentState.Uploading)] public async Task NotErrored_ResetsErroredStrikes(TorrentState state) { const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); ContextProvider.Set(nameof(QueueCleanerConfig), new QueueCleanerConfig { Id = Guid.NewGuid(), Errored = new ErroredConfig { MaxStrikes = 3 } }); ArrangeTorrent(hash, state); await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); await _fixture.Striker.Received(1) .ResetStrikeAsync(hash, Arg.Any(), StrikeType.Errored); } } 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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeFalse(); await _fixture.RuleEvaluator.DidNotReceive() .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeFalse(); await _fixture.RuleEvaluator.DidNotReceive() .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()) .Returns((true, DeleteReason.SlowSpeed, true, false)); // Rule matched // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); result.DeleteFromClient.ShouldBeTrue(); result.ChangeCategory.ShouldBeFalse(); } [Fact] public async Task SlowDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() { // 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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()) .Returns((true, DeleteReason.SlowSpeed, false, true)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); result.DeleteFromClient.ShouldBeFalse(); result.ChangeCategory.ShouldBeTrue(); } [Fact] public async Task SlowDownload_WhenAlternateSpeedActive_PassesAltSpeedProbeToEvaluator() { // Arrange const string hash = "test-hash"; QBitService sut = _fixture.CreateSut(); TorrentInfo torrentInfo = new() { Hash = hash, Name = "Test Torrent", State = TorrentState.Downloading, DownloadSpeed = 1000 }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); TorrentProperties properties = new() { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.ClientWrapper .GetAlternativeSpeedLimitsEnabledAsync() .Returns(true); Func>? capturedProbe = null; _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any(), Arg.Do>?>(probe => capturedProbe = probe)) .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((false, DeleteReason.None, false, false)); // Act await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert capturedProbe.ShouldNotBeNull(); (await capturedProbe!()).ShouldBeTrue(); } } 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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()) .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeFalse(); await _fixture.RuleEvaluator.DidNotReceive() .EvaluateStallRulesAsync(Arg.Any()); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((true, DeleteReason.Stalled, true, false)); // Rule matched // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Stalled); result.DeleteFromClient.ShouldBeTrue(); result.ChangeCategory.ShouldBeFalse(); } [Fact] public async Task StalledDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() { // Arrange const string hash = "test-hash"; var sut = _fixture.CreateSut(); var torrentInfo = new TorrentInfo { Hash = hash, Name = "Test Torrent", State = TorrentState.StalledDownload }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((true, DeleteReason.Stalled, true, true)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Stalled); result.DeleteFromClient.ShouldBeTrue(); result.ChangeCategory.ShouldBeTrue(); } } 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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); // Slow check is skipped because not in downloading state _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((true, DeleteReason.Stalled, true, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Stalled); await _fixture.RuleEvaluator.DidNotReceive() .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()); // Skipped await _fixture.RuleEvaluator.Received(1) .EvaluateStallRulesAsync(Arg.Any()); } [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 .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { torrentInfo }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(properties); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any(), Arg.Any>?>()) .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert result.ShouldRemove.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); } } public class BlockUnwantedFilesAsyncScenarios : QBitServiceTests { public BlockUnwantedFilesAsyncScenarios(QBitServiceFixture fixture) : base(fixture) { } private void SetMalwareBlockerContext(ContentBlockerConfig? config = null) { ContextProvider.Set(config ?? new ContentBlockerConfig()); ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr); _fixture.BlocklistProvider .GetBlocklistType(Arg.Any()) .Returns(BlocklistType.Blacklist); _fixture.BlocklistProvider .GetPatterns(Arg.Any()) .Returns(new ConcurrentBag()); _fixture.BlocklistProvider .GetRegexes(Arg.Any()) .Returns(new ConcurrentBag()); } private static TorrentInfo MakeTorrentInfo(string hash) => new() { Hash = hash, Name = "Malware Torrent", State = TorrentState.Downloading, DownloadSpeed = 1000, }; private static TorrentProperties MakeTorrentProperties(bool isPrivate = false) => new() { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(isPrivate) }, }, }; private void StubClient(string hash, IReadOnlyList files, bool isPrivate = false) { _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { MakeTorrentInfo(hash) }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync(hash) .Returns(MakeTorrentProperties(isPrivate)); _fixture.ClientWrapper .GetTorrentContentsAsync(hash) .Returns(files); } [Fact] public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason() { const string hash = "all-malware-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); StubClient(hash, [ new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, ]); _fixture.FilenameEvaluator .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(false); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); await _fixture.ClientWrapper .Received(1) .SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip); } [Fact] public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval() { const string hash = "all-malware-priority-throws-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); StubClient(hash, [ new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, ]); _fixture.FilenameEvaluator .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(false); _fixture.ClientWrapper .SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip) .ThrowsAsync(new HttpRequestException("rejected")); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); await _fixture.ClientWrapper .Received(1) .SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip); } [Fact] public async Task PartialMalware_CallsSetFilePriority_AndDoesNotMarkForRemoval() { const string hash = "partial-malware-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); StubClient(hash, [ new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal }, new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Normal }, ]); _fixture.FilenameEvaluator .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(false); _fixture.FilenameEvaluator .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(true); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); await _fixture.ClientWrapper .Received(1) .SetFilePriorityAsync(hash, 1, TorrentContentPriority.Skip); } [Fact] public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilePriority() { const string hash = "partial-malware-any-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); StubClient(hash, [ new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal }, new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Normal }, ]); _fixture.FilenameEvaluator .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(false); _fixture.FilenameEvaluator .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(true); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked); await _fixture.ClientWrapper .DidNotReceive() .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task NoUnwantedFiles_DoesNotMarkForRemoval() { const string hash = "clean-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); StubClient(hash, [ new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal }, ]); _fixture.FilenameEvaluator .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(true); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); await _fixture.ClientWrapper .DidNotReceive() .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked() { const string hash = "already-skipped-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); StubClient(hash, [ new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal }, new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Skip }, ]); _fixture.FilenameEvaluator .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(true); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); } [Fact] public async Task AllFilesAlreadySkipped_NoNewMalware_DoesNotMarkForRemoval() { const string hash = "all-skipped-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); StubClient(hash, [ new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Skip }, new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Skip }, ]); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); } [Fact] public async Task TorrentListIsNull_ReturnsNotFound() { const string hash = "deleted-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); _fixture.ClientWrapper .GetTorrentListAsync(Arg.Any()) .Returns((IReadOnlyList?)null); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeFalse(); result.ShouldRemove.ShouldBeFalse(); } [Fact] public async Task TrackersNotFound_ReturnsNotFound() { const string hash = "deleted-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) .Returns(new[] { MakeTorrentInfo(hash) }); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns((IReadOnlyList?)null); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeFalse(); result.ShouldRemove.ShouldBeFalse(); await _fixture.ClientWrapper.DidNotReceive() .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task OnlyPseudoTrackers_StillChecksFiles() { const string hash = "trackerless-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); StubClient(hash, [ new TorrentContent { Name = "installer.exe", Index = 0, Priority = TorrentContentPriority.Normal }, ]); _fixture.ClientWrapper .GetTorrentTrackersAsync(hash) .Returns(new TorrentTracker[] { new() { Url = "** [DHT] **", TrackerStatus = TorrentTrackerStatus.Working } }); _fixture.FilenameEvaluator .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(false); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); } [Fact] public async Task IgnorePrivate_PrivateTorrent_SkipsFileCheck() { const string hash = "private-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true }); StubClient(hash, [ new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, ], isPrivate: true); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.IsPrivate.ShouldBeTrue(); result.ShouldRemove.ShouldBeFalse(); await _fixture.ClientWrapper .DidNotReceive() .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); _fixture.FilenameEvaluator .DidNotReceive() .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()); } [Fact] public async Task FileWithNullIndex_ExcludedFromTotals_MalwareFileStillMarksAllFilesBlocked() { const string hash = "null-index-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); StubClient(hash, [ new TorrentContent { Name = "unindexed.mkv", Index = null, Priority = TorrentContentPriority.Normal }, new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, ]); _fixture.FilenameEvaluator .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(false); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); await _fixture.ClientWrapper .Received(1) .SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip); await _fixture.ClientWrapper .DidNotReceive() .SetFilePriorityAsync(hash, Arg.Is(i => i != 0), Arg.Any()); } [Fact] public async Task DryRun_AllFilesMalware_VerdictSet_ButNoSetFilePriorityCall() { const string hash = "dry-run-hash"; QBitService sut = _fixture.CreateSut(); SetMalwareBlockerContext(); StubClient(hash, [ new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, ]); _fixture.FilenameEvaluator .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) .Returns(false); _fixture.DryRunInterceptor .InterceptAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); await _fixture.ClientWrapper .DidNotReceive() .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); } } public class ApiKeyAuthScenarios : QBitServiceTests { public ApiKeyAuthScenarios(QBitServiceFixture fixture) : base(fixture) { } private static DownloadClientConfig CreateApiKeyConfig() => new() { Id = Guid.NewGuid(), Name = "Test Client", TypeName = DownloadClientTypeName.qBittorrent, Type = DownloadClientType.Torrent, Enabled = true, Host = new Uri("http://localhost:8080"), ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12", UrlBase = "" }; [Fact] public void Constructor_ApiKeyConfigured_SetsBearerAuthorizationHeader() { // Arrange HttpClient httpClient = new(); _fixture.HttpClientProvider.CreateClient(Arg.Any()).Returns(httpClient); DownloadClientConfig config = CreateApiKeyConfig(); // Act using QBitService sut = new( _fixture.Logger, _fixture.FilenameEvaluator, _fixture.Striker, _fixture.DryRunInterceptor, _fixture.HardLinkFileService, _fixture.HttpClientProvider, _fixture.EventPublisher, _fixture.BlocklistProvider, config, _fixture.RuleEvaluator, _fixture.SeedingRuleEvaluator, TimeProvider.System ); // Assert httpClient.DefaultRequestHeaders.Authorization?.Scheme.ShouldBe("Bearer"); httpClient.DefaultRequestHeaders.Authorization?.Parameter.ShouldBe(config.ApiKey); } [Fact] public void Constructor_WhitespaceApiKey_DoesNotSetAuthorizationHeader() { // Arrange HttpClient httpClient = new(); _fixture.HttpClientProvider.CreateClient(Arg.Any()).Returns(httpClient); DownloadClientConfig config = CreateApiKeyConfig(); config.ApiKey = " "; // Act using QBitService sut = new( _fixture.Logger, _fixture.FilenameEvaluator, _fixture.Striker, _fixture.DryRunInterceptor, _fixture.HardLinkFileService, _fixture.HttpClientProvider, _fixture.EventPublisher, _fixture.BlocklistProvider, config, _fixture.RuleEvaluator, _fixture.SeedingRuleEvaluator, TimeProvider.System ); // Assert httpClient.DefaultRequestHeaders.Authorization.ShouldBeNull(); } [Fact] public async Task LoginAsync_CredentialsConfigured_LogsIn() { // Arrange DownloadClientConfig config = CreateApiKeyConfig(); config.ApiKey = null; config.Username = "admin"; config.Password = "admin"; QBitService sut = _fixture.CreateSut(config); // Act await sut.LoginAsync(); // Assert await _fixture.ClientWrapper .Received(1) .LoginAsync(config.Username, config.Password); } [Fact] public async Task HealthCheckAsync_CredentialsConfigured_LogsInAndReportsHealthy() { // Arrange DownloadClientConfig config = CreateApiKeyConfig(); config.ApiKey = null; config.Username = "admin"; config.Password = "admin"; QBitService sut = _fixture.CreateSut(config); // Act HealthCheckResult result = await sut.HealthCheckAsync(); // Assert result.IsHealthy.ShouldBeTrue(); await _fixture.ClientWrapper .Received(1) .LoginAsync(config.Username, config.Password); await _fixture.ClientWrapper .DidNotReceive() .GetAlternativeSpeedLimitsEnabledAsync(); } [Fact] public async Task HealthCheckAsync_WhitespaceApiKey_UsesCredentials() { // Arrange DownloadClientConfig config = CreateApiKeyConfig(); config.ApiKey = " "; config.Username = "admin"; config.Password = "admin"; QBitService sut = _fixture.CreateSut(config); // Act HealthCheckResult result = await sut.HealthCheckAsync(); // Assert result.IsHealthy.ShouldBeTrue(); await _fixture.ClientWrapper .Received(1) .LoginAsync(config.Username, config.Password); await _fixture.ClientWrapper .DidNotReceive() .GetAlternativeSpeedLimitsEnabledAsync(); } [Fact] public async Task LoginAsync_ApiKeyConfigured_SkipsLogin() { // Arrange QBitService sut = _fixture.CreateSut(CreateApiKeyConfig()); // Act await sut.LoginAsync(); // Assert await _fixture.ClientWrapper .DidNotReceive() .LoginAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task HealthCheckAsync_ApiKeyConfigured_ChecksAlternativeSpeedLimitsEndpoint() { // Arrange QBitService sut = _fixture.CreateSut(CreateApiKeyConfig()); _fixture.ClientWrapper.GetAlternativeSpeedLimitsEnabledAsync().Returns(false); // Act var result = await sut.HealthCheckAsync(); // Assert result.IsHealthy.ShouldBeTrue(); await _fixture.ClientWrapper.Received(1).GetAlternativeSpeedLimitsEnabledAsync(); await _fixture.ClientWrapper .DidNotReceive() .LoginAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task HealthCheckAsync_ApiKeyConfigured_EndpointThrows_ReportsUnhealthy() { // Arrange QBitService sut = _fixture.CreateSut(CreateApiKeyConfig()); _fixture.ClientWrapper .GetAlternativeSpeedLimitsEnabledAsync() .ThrowsAsync(new HttpRequestException("403 Forbidden")); // Act var result = await sut.HealthCheckAsync(); // Assert result.IsHealthy.ShouldBeFalse(); await _fixture.ClientWrapper .DidNotReceive() .LoginAsync(Arg.Any(), Arg.Any()); } } }