using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using NSubstitute; 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()) .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()) .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(); } } 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()) .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_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()); } [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()); } [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()) .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()) .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(); } } 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()) .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()); // 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()) .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); } } }