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 Moq; using Newtonsoft.Json.Linq; using QBittorrent.Client; 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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(Array.Empty()); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.Found); Assert.False(result.ShouldRemove); Assert.Equal(DeleteReason.None, result.DeleteReason); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { ignoredCategory }); // Assert Assert.True(result.Found); Assert.False(result.ShouldRemove); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(true) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); _fixture.RuleEvaluator .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.True(result.Found); Assert.True(result.IsPrivate); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); _fixture.RuleEvaluator .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.True(result.Found); Assert.False(result.IsPrivate); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync((TorrentProperties?)null); // Properties not found // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.Found); Assert.False(result.ShouldRemove); Assert.False(result.IsPrivate); Assert.Equal(DeleteReason.None, result.DeleteReason); Assert.False(result.DeleteFromClient); } } 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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(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 Assert.True(result.ShouldRemove); Assert.Equal(DeleteReason.AllFilesSkippedByQBit, result.DeleteReason); Assert.True(result.DeleteFromClient); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(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 Assert.True(result.ShouldRemove); Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason); Assert.True(result.DeleteFromClient); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip }, new TorrentContent { Index = 1, Priority = TorrentContentPriority.Normal } // At least one wanted }); _fixture.RuleEvaluator .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); _fixture.RuleEvaluator .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.ShouldRemove); } } 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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.Striker .Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny(), (ushort)3, StrikeType.DownloadingMetadata)) .ReturnsAsync(false); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.ShouldRemove); _fixture.Striker.Verify( x => x.StrikeAndCheckLimit(hash, It.IsAny(), (ushort)3, StrikeType.DownloadingMetadata), Times.Once); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.Striker .Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny(), (ushort)3, StrikeType.DownloadingMetadata)) .ReturnsAsync(true); // Strike limit exceeded // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.True(result.ShouldRemove); Assert.Equal(DeleteReason.DownloadingMetadata, result.DeleteReason); Assert.True(result.DeleteFromClient); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.ShouldRemove); _fixture.Striker.Verify( x => x.StrikeAndCheckLimit(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } } 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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.ShouldRemove); _fixture.RuleEvaluator.Verify( x => x.EvaluateSlowRulesAsync(It.IsAny()), Times.Never); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.ShouldRemove); _fixture.RuleEvaluator.Verify( x => x.EvaluateSlowRulesAsync(It.IsAny()), Times.Never); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) .ReturnsAsync((true, DeleteReason.SlowSpeed, true)); // Rule matched // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.True(result.ShouldRemove); Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason); Assert.True(result.DeleteFromClient); } } 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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.ShouldRemove); _fixture.RuleEvaluator.Verify( x => x.EvaluateStallRulesAsync(It.IsAny()), Times.Never); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) .ReturnsAsync((true, DeleteReason.Stalled, true)); // Rule matched // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.True(result.ShouldRemove); Assert.Equal(DeleteReason.Stalled, result.DeleteReason); Assert.True(result.DeleteFromClient); } } 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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); // Slow check is skipped because not in downloading state _fixture.RuleEvaluator .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) .ReturnsAsync((true, DeleteReason.Stalled, true)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.True(result.ShouldRemove); Assert.Equal(DeleteReason.Stalled, result.DeleteReason); _fixture.RuleEvaluator.Verify( x => x.EvaluateSlowRulesAsync(It.IsAny()), Times.Never); // Skipped _fixture.RuleEvaluator.Verify( x => x.EvaluateStallRulesAsync(It.IsAny()), Times.Once); } [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 .Setup(x => x.GetTorrentListAsync(It.Is(q => q.Hashes != null && q.Hashes.Contains(hash)))) .ReturnsAsync(new[] { torrentInfo }); _fixture.ClientWrapper .Setup(x => x.GetTorrentTrackersAsync(hash)) .ReturnsAsync(Array.Empty()); var properties = new TorrentProperties { AdditionalData = new Dictionary { { "is_private", JToken.FromObject(false) } } }; _fixture.ClientWrapper .Setup(x => x.GetTorrentPropertiesAsync(hash)) .ReturnsAsync(properties); _fixture.ClientWrapper .Setup(x => x.GetTorrentContentsAsync(hash)) .ReturnsAsync(new[] { new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } }); _fixture.RuleEvaluator .Setup(x => x.EvaluateSlowRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); _fixture.RuleEvaluator .Setup(x => x.EvaluateStallRulesAsync(It.IsAny())) .ReturnsAsync((false, DeleteReason.None, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); // Assert Assert.False(result.ShouldRemove); Assert.Equal(DeleteReason.None, result.DeleteReason); } } }