Improve seeding rule customization (#553)

This commit is contained in:
Flaminel
2026-04-11 17:13:41 +03:00
committed by GitHub
parent 53fc5eff3b
commit 4e9d20db0a
85 changed files with 7364 additions and 2509 deletions

View File

@@ -0,0 +1,459 @@
using System.Text.Json;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
public class SeedingRulesControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly SeedingRulesController _controller;
public SeedingRulesControllerTests()
{
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<SeedingRulesController>>();
_controller = new SeedingRulesController(logger, _dataContext);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private static SeedingRuleRequest CreateValidRequest(
string name = "Test Rule",
List<string>? categories = null,
List<string>? trackerPatterns = null,
List<string>? tagsAny = null,
List<string>? tagsAll = null,
int? priority = null,
double maxRatio = 2.0,
double minSeedTime = 0,
double maxSeedTime = -1,
bool deleteSourceFiles = true)
{
return new SeedingRuleRequest
{
Name = name,
Categories = categories ?? ["movies"],
TrackerPatterns = trackerPatterns ?? [],
TagsAny = tagsAny ?? [],
TagsAll = tagsAll ?? [],
Priority = priority,
PrivacyType = TorrentPrivacyType.Both,
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
DeleteSourceFiles = deleteSourceFiles,
};
}
private static JsonElement GetJsonBody(IActionResult result)
{
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
return JsonDocument.Parse(json).RootElement;
}
private static JsonElement GetCreatedJsonBody(IActionResult result)
{
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
var json = JsonSerializer.Serialize(createdResult.Value);
return JsonDocument.Parse(json).RootElement;
}
// ──────────────────────────────────────────────────────────────────────
// GetSeedingRules
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task GetSeedingRules_EmptyRules_ReturnsEmptyList()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var result = await _controller.GetSeedingRules(client.Id);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var array = JsonDocument.Parse(json).RootElement;
array.GetArrayLength().ShouldBe(0);
}
[Fact]
public async Task GetSeedingRules_ReturnsRulesOrderedByPriority()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule C", priority: 3);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule A", priority: 1);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule B", priority: 2);
var result = await _controller.GetSeedingRules(client.Id);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var array = JsonDocument.Parse(json).RootElement;
array.GetArrayLength().ShouldBe(3);
array[0].GetProperty("name").GetString().ShouldBe("Rule A");
array[1].GetProperty("name").GetString().ShouldBe("Rule B");
array[2].GetProperty("name").GetString().ShouldBe("Rule C");
}
[Fact]
public async Task GetSeedingRules_NonExistentClient_ReturnsNotFound()
{
var result = await _controller.GetSeedingRules(Guid.NewGuid());
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task GetSeedingRules_QBitClient_ReturnsTagFields()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id,
tagsAny: ["hd", "private"], tagsAll: ["required"]);
var result = await _controller.GetSeedingRules(client.Id);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var rule = JsonDocument.Parse(json).RootElement[0];
rule.GetProperty("tagsAny").GetArrayLength().ShouldBe(2);
rule.GetProperty("tagsAll").GetArrayLength().ShouldBe(1);
rule.GetProperty("tagsAll")[0].GetString().ShouldBe("required");
}
[Fact]
public async Task GetSeedingRules_DelugeClient_ReturnsEmptyTagFields()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.Deluge, "Test Deluge");
SeedingRulesTestDataFactory.AddDelugeSeedingRule(_dataContext, client.Id);
var result = await _controller.GetSeedingRules(client.Id);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var rule = JsonDocument.Parse(json).RootElement[0];
rule.GetProperty("tagsAny").GetArrayLength().ShouldBe(0);
rule.GetProperty("tagsAll").GetArrayLength().ShouldBe(0);
}
// ──────────────────────────────────────────────────────────────────────
// CreateSeedingRule
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task CreateSeedingRule_ValidRequest_ReturnsCreated()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(name: "Movies Rule", categories: ["movies", "films"]);
var result = await _controller.CreateSeedingRule(client.Id, request);
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
createdResult.StatusCode.ShouldBe(201);
var body = GetCreatedJsonBody(result);
body.GetProperty("Name").GetString().ShouldBe("Movies Rule");
body.GetProperty("Categories").GetArrayLength().ShouldBe(2);
}
[Fact]
public async Task CreateSeedingRule_AutoAssignsPriority_WhenNotProvided()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest();
var result = await _controller.CreateSeedingRule(client.Id, request);
var body = GetCreatedJsonBody(result);
body.GetProperty("Priority").GetInt32().ShouldBe(1);
}
[Fact]
public async Task CreateSeedingRule_AutoAssignsSequentialPriority()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
var request = CreateValidRequest(name: "Second Rule", categories: ["tv"]);
var result = await _controller.CreateSeedingRule(client.Id, request);
var body = GetCreatedJsonBody(result);
body.GetProperty("Priority").GetInt32().ShouldBe(2);
}
[Fact]
public async Task CreateSeedingRule_DuplicatePriority_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
var request = CreateValidRequest(priority: 1);
var result = await _controller.CreateSeedingRule(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task CreateSeedingRule_NonExistentClient_ReturnsNotFound()
{
var request = CreateValidRequest();
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task CreateSeedingRule_EmptyCategories_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(categories: []);
var result = await _controller.CreateSeedingRule(client.Id, request);
// Validate() throws ValidationException → caught → BadRequest
result.ShouldBeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task CreateSeedingRule_SanitizesWhitespaceInLists()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(
trackerPatterns: ["", " ", "valid.com", " trimmed.com "]);
var result = await _controller.CreateSeedingRule(client.Id, request);
var body = GetCreatedJsonBody(result);
var patterns = body.GetProperty("TrackerPatterns");
patterns.GetArrayLength().ShouldBe(2);
patterns[0].GetString().ShouldBe("valid.com");
patterns[1].GetString().ShouldBe("trimmed.com");
}
[Fact]
public async Task CreateSeedingRule_ForTransmission_CreatesTransmissionRule()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext,
DownloadClientTypeName.Transmission, "Test Transmission");
var request = CreateValidRequest(tagsAny: ["tag1"]);
var result = await _controller.CreateSeedingRule(client.Id, request);
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
createdResult.Value.ShouldBeOfType<TransmissionSeedingRule>();
}
// ──────────────────────────────────────────────────────────────────────
// UpdateSeedingRule
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateSeedingRule_ValidRequest_ReturnsOk()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var request = CreateValidRequest(name: "Updated Name", categories: ["tv", "anime"]);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.Name.ShouldBe("Updated Name");
updated.Categories.ShouldBe(new List<string> { "tv", "anime" });
}
[Fact]
public async Task UpdateSeedingRule_DoesNotChangePriority()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 5);
var request = CreateValidRequest(priority: 1);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.Priority.ShouldBe(5);
}
[Fact]
public async Task UpdateSeedingRule_UpdatesTagsForTagFilterableClient()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var request = CreateValidRequest(tagsAny: ["new-tag"], tagsAll: ["must-have"]);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.TagsAny.ShouldBe(new List<string> { "new-tag" });
updated.TagsAll.ShouldBe(new List<string> { "must-have" });
}
[Fact]
public async Task UpdateSeedingRule_NonExistentRule_ReturnsNotFound()
{
var request = CreateValidRequest();
var result = await _controller.UpdateSeedingRule(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task UpdateSeedingRule_ValidationFailure_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
// Both maxRatio and maxSeedTime negative → validation failure
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
}
// ──────────────────────────────────────────────────────────────────────
// ReorderSeedingRules
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ReorderSeedingRules_ValidRequest_ReturnsNoContent()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule2.Id, rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<NoContentResult>();
}
[Fact]
public async Task ReorderSeedingRules_AssignsSequentialPriorities()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
var rule3 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "C", priority: 3);
// Reverse order
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule3.Id, rule2.Id, rule1.Id] };
await _controller.ReorderSeedingRules(client.Id, request);
// Verify via GET
var getResult = await _controller.GetSeedingRules(client.Id);
var okResult = getResult.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var array = JsonDocument.Parse(json).RootElement;
array[0].GetProperty("name").GetString().ShouldBe("C");
array[0].GetProperty("priority").GetInt32().ShouldBe(1);
array[1].GetProperty("name").GetString().ShouldBe("B");
array[1].GetProperty("priority").GetInt32().ShouldBe(2);
array[2].GetProperty("name").GetString().ShouldBe("A");
array[2].GetProperty("priority").GetInt32().ShouldBe(3);
}
[Fact]
public async Task ReorderSeedingRules_NonExistentClient_ReturnsNotFound()
{
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task ReorderSeedingRules_DuplicateIds_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task ReorderSeedingRules_WrongCount_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
// Only send 1 of 2 IDs
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task ReorderSeedingRules_UnknownRuleId_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
}
// ──────────────────────────────────────────────────────────────────────
// DeleteSeedingRule
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task DeleteSeedingRule_ExistingRule_ReturnsNoContent()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var result = await _controller.DeleteSeedingRule(rule.Id);
result.ShouldBeOfType<NoContentResult>();
}
[Fact]
public async Task DeleteSeedingRule_VerifiesRuleRemoved()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
await _controller.DeleteSeedingRule(rule.Id);
// Verify rule no longer exists
var getResult = await _controller.GetSeedingRules(client.Id);
var okResult = getResult.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var array = JsonDocument.Parse(json).RootElement;
array.GetArrayLength().ShouldBe(0);
}
[Fact]
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
{
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
result.ShouldBeOfType<NotFoundObjectResult>();
}
}

View File

@@ -0,0 +1,205 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
/// <summary>
/// Factory for creating SQLite in-memory contexts for SeedingRulesController tests
/// </summary>
public static class SeedingRulesTestDataFactory
{
public static DataContext CreateDataContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connection)
.Options;
var context = new DataContext(options);
context.Database.EnsureCreated();
SeedDefaultData(context);
return context;
}
private static void SeedDefaultData(DataContext context)
{
context.GeneralConfigs.Add(new GeneralConfig
{
Id = Guid.NewGuid(),
DryRun = false,
IgnoredDownloads = [],
Log = new LoggingConfig()
});
context.ArrConfigs.AddRange(
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
);
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
FailedImport = new FailedImportConfig()
});
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
DeletePrivate = false,
Sonarr = new BlocklistSettings { Enabled = false },
Radarr = new BlocklistSettings { Enabled = false },
Lidarr = new BlocklistSettings { Enabled = false },
Readarr = new BlocklistSettings { Enabled = false },
Whisparr = new BlocklistSettings { Enabled = false }
});
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = []
});
context.SeekerConfigs.Add(new SeekerConfig
{
Id = Guid.NewGuid(),
SearchEnabled = true,
ProactiveSearchEnabled = false
});
context.SaveChanges();
}
public static DownloadClientConfig AddDownloadClient(
DataContext context,
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
string name = "Test qBittorrent")
{
var config = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = name,
TypeName = typeName,
Type = DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "admin"
};
context.DownloadClients.Add(config);
context.SaveChanges();
return config;
}
public static QBitSeedingRule AddQBitSeedingRule(
DataContext context,
Guid downloadClientId,
string name = "Test Rule",
int priority = 1,
List<string>? categories = null,
List<string>? trackerPatterns = null,
List<string>? tagsAny = null,
List<string>? tagsAll = null,
double maxRatio = 2.0,
double minSeedTime = 0,
double maxSeedTime = -1)
{
var rule = new QBitSeedingRule
{
Id = Guid.NewGuid(),
DownloadClientConfigId = downloadClientId,
Name = name,
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = trackerPatterns ?? [],
TagsAny = tagsAny ?? [],
TagsAll = tagsAll ?? [],
PrivacyType = TorrentPrivacyType.Both,
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
DeleteSourceFiles = true,
};
context.QBitSeedingRules.Add(rule);
context.SaveChanges();
return rule;
}
public static DelugeSeedingRule AddDelugeSeedingRule(
DataContext context,
Guid downloadClientId,
string name = "Test Rule",
int priority = 1,
List<string>? categories = null,
double maxRatio = 2.0,
double maxSeedTime = -1)
{
var rule = new DelugeSeedingRule
{
Id = Guid.NewGuid(),
DownloadClientConfigId = downloadClientId,
Name = name,
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = [],
PrivacyType = TorrentPrivacyType.Both,
MaxRatio = maxRatio,
MinSeedTime = 0,
MaxSeedTime = maxSeedTime,
DeleteSourceFiles = true,
};
context.DelugeSeedingRules.Add(rule);
context.SaveChanges();
return rule;
}
public static TransmissionSeedingRule AddTransmissionSeedingRule(
DataContext context,
Guid downloadClientId,
string name = "Test Rule",
int priority = 1,
List<string>? categories = null,
double maxRatio = 2.0,
double maxSeedTime = -1)
{
var rule = new TransmissionSeedingRule
{
Id = Guid.NewGuid(),
DownloadClientConfigId = downloadClientId,
Name = name,
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = [],
TagsAny = [],
TagsAll = [],
PrivacyType = TorrentPrivacyType.Both,
MaxRatio = maxRatio,
MinSeedTime = 0,
MaxSeedTime = maxSeedTime,
DeleteSourceFiles = true,
};
context.TransmissionSeedingRules.Add(rule);
context.SaveChanges();
return rule;
}
}

View File

@@ -58,8 +58,9 @@ public static class ServicesDI
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddScoped<FileReader>()
.AddScoped<IRuleManager, RuleManager>()
.AddScoped<IRuleEvaluator, RuleEvaluator>()
.AddScoped<IQueueRuleManager, QueueRuleManager>()
.AddScoped<IQueueRuleEvaluator, QueueRuleEvaluator>()
.AddScoped<ISeedingRuleEvaluator, SeedingRuleEvaluator>()
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
.AddScoped<IStatsService, StatsService>()
.AddSingleton<IJobManagementService, JobManagementService>()

View File

@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public record ReorderSeedingRulesRequest
{
/// <summary>
/// IDs of seeding rules in the desired priority order (first = highest priority).
/// </summary>
[Required]
public List<Guid> OrderedIds { get; init; } = [];
}

View File

@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
@@ -8,6 +8,36 @@ public record SeedingRuleRequest
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Categories this rule applies to. At least one must be specified.
/// </summary>
[Required]
[MinLength(1, ErrorMessage = "At least one category must be specified.")]
public List<string> Categories { get; init; } = [];
/// <summary>
/// Tracker domain suffixes to match (e.g. "tracker.example.com"). Empty = any tracker.
/// </summary>
public List<string> TrackerPatterns { get; init; } = [];
/// <summary>
/// Torrent must have at least one of these tags/labels. Accepted for all clients;
/// silently ignored for Deluge, rTorrent, and µTorrent.
/// </summary>
public List<string> TagsAny { get; init; } = [];
/// <summary>
/// Torrent must have ALL of these tags/labels. Accepted for all clients;
/// silently ignored for Deluge, rTorrent, and µTorrent.
/// </summary>
public List<string> TagsAll { get; init; } = [];
/// <summary>
/// Evaluation priority (lower = evaluated first). Auto-assigned if not provided.
/// </summary>
[Range(1, int.MaxValue, ErrorMessage = "Priority must be a positive integer.")]
public int? Priority { get; init; }
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
@@ -32,4 +62,4 @@ public record SeedingRuleRequest
/// Whether to delete the source files when cleaning the download.
/// </summary>
public bool DeleteSourceFiles { get; init; } = true;
}
}

View File

@@ -72,6 +72,11 @@ public sealed class DownloadCleanerConfigController : ControllerBase
{
id = r.Id,
name = r.Name,
categories = r.Categories,
trackerPatterns = r.TrackerPatterns,
tagsAny = (r as ITagFilterable)?.TagsAny ?? new List<string>(),
tagsAll = (r as ITagFilterable)?.TagsAll ?? new List<string>(),
priority = r.Priority,
privacyType = r.PrivacyType,
maxRatio = r.MaxRatio,
minSeedTime = r.MinSeedTime,

View File

@@ -44,7 +44,21 @@ public class SeedingRulesController : ControllerBase
var rules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
return Ok(rules);
return Ok(rules.Select(r => new
{
id = r.Id,
name = r.Name,
categories = r.Categories,
trackerPatterns = r.TrackerPatterns,
tagsAny = (r as ITagFilterable)?.TagsAny ?? new List<string>(),
tagsAll = (r as ITagFilterable)?.TagsAll ?? new List<string>(),
priority = r.Priority,
privacyType = r.PrivacyType,
maxRatio = r.MaxRatio,
minSeedTime = r.MinSeedTime,
maxSeedTime = r.MaxSeedTime,
deleteSourceFiles = r.DeleteSourceFiles,
}));
}
catch (Exception ex)
{
@@ -78,22 +92,15 @@ public class SeedingRulesController : ControllerBase
}
var existingRules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
var duplicate = existingRules.FirstOrDefault(r =>
r.Name.Equals(ruleDto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == ruleDto.PrivacyType);
if (duplicate is not null)
if (ruleDto.Priority.HasValue && existingRules.Any(r => r.Priority == ruleDto.Priority.Value))
{
return BadRequest(new { Message = "A seeding rule with this name and privacy type already exists for this client" });
return BadRequest(new { Message = $"A seeding rule with priority {ruleDto.Priority.Value} already exists for this client" });
}
var overlapError = GetPrivacyTypeOverlapError(ruleDto.Name.Trim(), ruleDto.PrivacyType, existingRules, excludeId: null);
if (overlapError is not null)
{
return BadRequest(new { Message = overlapError });
}
int priority = ruleDto.Priority ?? (existingRules.Count == 0 ? 1 : existingRules.Max(r => r.Priority) + 1);
var rule = CreateRule(client.TypeName, client.Id, ruleDto);
var rule = CreateRule(client.TypeName, client.Id, ruleDto, priority);
rule.Validate();
AddRuleToDbSet(rule);
@@ -139,30 +146,21 @@ public class SeedingRulesController : ControllerBase
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
}
// Check for duplicate name+privacyType on the same client, excluding this rule
var clientRules = await SeedingRuleHelper.GetForClientIdAsync(_dataContext, existingRule.DownloadClientConfigId);
var duplicate = clientRules.FirstOrDefault(r =>
r.Id != id &&
r.Name.Equals(ruleDto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == ruleDto.PrivacyType);
if (duplicate is not null)
{
return BadRequest(new { Message = "A seeding rule with this name and privacy type already exists for this client" });
}
var overlapError = GetPrivacyTypeOverlapError(ruleDto.Name.Trim(), ruleDto.PrivacyType, clientRules, excludeId: id);
if (overlapError is not null)
{
return BadRequest(new { Message = overlapError });
}
existingRule.Name = ruleDto.Name.Trim();
existingRule.Categories = SanitizeStringList(ruleDto.Categories);
existingRule.TrackerPatterns = SanitizeStringList(ruleDto.TrackerPatterns);
existingRule.PrivacyType = ruleDto.PrivacyType;
existingRule.MaxRatio = ruleDto.MaxRatio;
existingRule.MinSeedTime = ruleDto.MinSeedTime;
existingRule.MaxSeedTime = ruleDto.MaxSeedTime;
existingRule.DeleteSourceFiles = ruleDto.DeleteSourceFiles;
// Priority is intentionally NOT updated here — use the reorder endpoint
if (existingRule is ITagFilterable tagFilterable)
{
tagFilterable.TagsAny = SanitizeStringList(ruleDto.TagsAny);
tagFilterable.TagsAll = SanitizeStringList(ruleDto.TagsAll);
}
existingRule.Validate();
@@ -188,6 +186,68 @@ public class SeedingRulesController : ControllerBase
}
}
[HttpPut("{downloadClientId}/reorder")]
public async Task<IActionResult> ReorderSeedingRules(Guid downloadClientId, [FromBody] ReorderSeedingRulesRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
List<ISeedingRule> rules = await SeedingRuleHelper.GetForClientTrackedAsync(_dataContext, client);
if (request.OrderedIds.Distinct().Count() != request.OrderedIds.Count)
{
return BadRequest(new { Message = "Duplicate rule IDs are not allowed" });
}
if (request.OrderedIds.Count != rules.Count)
{
return BadRequest(new { Message = $"Expected {rules.Count} rule IDs but received {request.OrderedIds.Count}. All rules must be included." });
}
foreach (Guid id in request.OrderedIds.Where(id => rules.All(r => r.Id != id)))
{
return BadRequest(new { Message = $"Rule with ID {id} not found for client {downloadClientId}" });
}
int priority = 1;
var lookup = rules.ToDictionary(r => r.Id);
foreach (var id in request.OrderedIds)
{
lookup[id].Priority = priority++;
}
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Reordered {Count} seeding rules for client {ClientId}", rules.Count, downloadClientId);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reorder seeding rules for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to reorder seeding rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteSeedingRule(Guid id)
{
@@ -219,44 +279,27 @@ public class SeedingRulesController : ControllerBase
}
}
private static string? GetPrivacyTypeOverlapError(
string name,
TorrentPrivacyType privacyType,
IEnumerable<ISeedingRule> existingRules,
Guid? excludeId)
private static List<string> SanitizeStringList(List<string> list)
=> list.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToList();
private static ISeedingRule CreateRule(DownloadClientTypeName typeName, Guid clientId, SeedingRuleRequest dto, int priority)
{
if (privacyType == TorrentPrivacyType.Both)
{
var hasConflict = existingRules.Any(r =>
r.Id != excludeId &&
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType != TorrentPrivacyType.Both);
var categories = SanitizeStringList(dto.Categories);
var trackerPatterns = SanitizeStringList(dto.TrackerPatterns);
var tagsAny = SanitizeStringList(dto.TagsAny);
var tagsAll = SanitizeStringList(dto.TagsAll);
return hasConflict
? "A 'Both' rule cannot coexist with a Public or Private rule for the same category"
: null;
}
else
{
var hasConflict = existingRules.Any(r =>
r.Id != excludeId &&
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == TorrentPrivacyType.Both);
return hasConflict
? "A Public or Private rule cannot coexist with a 'Both' rule for the same category"
: null;
}
}
private ISeedingRule CreateRule(DownloadClientTypeName typeName, Guid clientId, SeedingRuleRequest dto)
{
return typeName switch
{
DownloadClientTypeName.qBittorrent => new QBitSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
TagsAny = tagsAny,
TagsAll = tagsAll,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
@@ -267,6 +310,9 @@ public class SeedingRulesController : ControllerBase
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
@@ -277,6 +323,11 @@ public class SeedingRulesController : ControllerBase
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
TagsAny = tagsAny,
TagsAll = tagsAll,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
@@ -287,6 +338,9 @@ public class SeedingRulesController : ControllerBase
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
@@ -297,6 +351,9 @@ public class SeedingRulesController : ControllerBase
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
@@ -350,5 +407,4 @@ public class SeedingRulesController : ControllerBase
break;
}
}
}

View File

@@ -16,15 +16,57 @@ internal static class SeedingRuleHelper
return client.TypeName switch
{
DownloadClientTypeName.qBittorrent => (await ctx.QBitSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => (await ctx.DelugeSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => (await ctx.TransmissionSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => (await ctx.UTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => (await ctx.RTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
_ => [],
};
}
/// <summary>
/// Queries the appropriate per-type seeding rules table for a single client with change tracking enabled.
/// Use this when you need to modify and save the returned entities.
/// </summary>
public static async Task<List<ISeedingRule>> GetForClientTrackedAsync(DataContext ctx, DownloadClientConfig client)
{
return client.TypeName switch
{
DownloadClientTypeName.qBittorrent => (await ctx.QBitSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => (await ctx.DelugeSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => (await ctx.TransmissionSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => (await ctx.UTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => (await ctx.RTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
_ => [],
};
}
@@ -55,11 +97,26 @@ internal static class SeedingRuleHelper
{
return client.TypeName switch
{
DownloadClientTypeName.qBittorrent => qbitRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => delugeRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => transmissionRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => utorrentRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => rtorrentRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.qBittorrent => qbitRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => delugeRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => transmissionRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => utorrentRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => rtorrentRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
_ => [],
};
}

View File

@@ -27,11 +27,23 @@ public interface ITorrentItemWrapper
long SeedingTimeSeconds { get; }
string? Category { get; set; }
string SavePath { get; }
/// <summary>
/// Tracker domains extracted from all trackers associated with this torrent.
/// Used for tracker-based seeding rule matching.
/// </summary>
IReadOnlyList<string> TrackerDomains { get; }
/// <summary>
/// Tags or labels associated with this torrent.
/// Populated for qBittorrent (tags) and Transmission (labels). Empty for other clients.
/// </summary>
IReadOnlyList<string> Tags { get; }
bool IsDownloading();
bool IsStalled();
/// <summary>

View File

@@ -3,7 +3,8 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
@@ -38,8 +39,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
@@ -62,8 +63,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
@@ -79,8 +80,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync((List<DownloadStatus>?)null);
.GetStatusForAllTorrents()
.Returns((List<DownloadStatus>?)null);
// Act
var result = await sut.GetSeedingDownloads();
@@ -102,8 +103,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
.GetStatusForAllTorrents()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
@@ -135,8 +136,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var categories = new List<ISeedingRule>
{
new DelugeSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new DelugeSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new DelugeSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new DelugeSeedingRule { Name = "tv", Categories = ["tv"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -162,7 +163,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var categories = new List<ISeedingRule>
{
new DelugeSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new DelugeSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -186,7 +187,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var categories = new List<ISeedingRule>
{
new DelugeSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new DelugeSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -298,18 +299,18 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string>());
.GetLabels()
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.CreateLabel("new-label"))
.CreateLabel("new-label")
.Returns(Task.CompletedTask);
// Act
await sut.CreateCategoryAsync("new-label");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel("new-label"), Times.Once);
await _fixture.ClientWrapper.Received(1).CreateLabel("new-label");
}
[Fact]
@@ -319,14 +320,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string> { "existing" });
.GetLabels()
.Returns(new List<string> { "existing" });
// Act
await sut.CreateCategoryAsync("existing");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().CreateLabel(Arg.Any<string>());
}
[Fact]
@@ -336,14 +337,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string> { "Existing" });
.GetLabels()
.Returns(new List<string> { "Existing" });
// Act
await sut.CreateCategoryAsync("existing");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().CreateLabel(Arg.Any<string>());
}
}
@@ -359,20 +360,19 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), true)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, true);
await sut.DeleteDownload(mockTorrent, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), true);
}
[Fact]
@@ -381,20 +381,19 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
.DeleteTorrents(Arg.Any<List<string>>(), true)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, true);
await sut.DeleteDownload(mockTorrent, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("uppercase-hash")), true);
}
[Fact]
@@ -403,20 +402,19 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), false)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, false);
await sut.DeleteDownload(mockTorrent, false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), false);
}
}
@@ -442,7 +440,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -461,7 +459,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -485,7 +483,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -509,7 +507,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -533,7 +531,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -554,14 +552,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
.GetTorrentFiles("hash1")
.Throws(new InvalidOperationException("Failed to get files"));
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -582,8 +580,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles("hash1")
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -592,16 +590,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabel("hash1", "unlinked"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabel("hash1", "unlinked");
}
[Fact]
@@ -622,8 +619,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles("hash1")
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -632,14 +629,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -660,8 +657,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles("hash1")
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -670,14 +667,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -698,8 +695,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles("hash1")
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -709,16 +706,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
_fixture.HardLinkFileService.Received(1)
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -739,8 +735,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles("hash1")
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -749,16 +745,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabel("hash1", "unlinked"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabel("hash1", "unlinked");
}
}
}

View File

@@ -8,42 +8,46 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using NSubstitute;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceFixture : IDisposable
{
public Mock<ILogger<DelugeService>> Logger { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IDelugeClientWrapper> ClientWrapper { get; }
public ILogger<DelugeService> Logger { get; private set; }
public IFilenameEvaluator FilenameEvaluator { get; private set; }
public IStriker Striker { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
public IQueueRuleManager RuleManager { get; private set; }
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
public IDelugeClientWrapper ClientWrapper { get; private set; }
public DelugeServiceFixture()
{
Logger = new Mock<ILogger<DelugeService>>();
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IDelugeClientWrapper>();
Logger = Substitute.For<ILogger<DelugeService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<IDelugeClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
@@ -65,42 +69,45 @@ public class DelugeServiceFixture : IDisposable
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.CreateClient(Arg.Any<DownloadClientConfig>())
.Returns(httpClient);
return new DelugeService(
Logger.Object,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
Logger,
FilenameEvaluator,
Striker,
DryRunInterceptor,
HardLinkFileService,
HttpClientProvider,
EventPublisher,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
RuleEvaluator,
SeedingRuleEvaluator,
ClientWrapper
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
Logger = Substitute.For<ILogger<DelugeService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<IDelugeClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}

View File

@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Moq;
using NSubstitute;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
@@ -30,8 +30,8 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync((DownloadStatus?)null);
.GetTorrentStatus(hash)
.Returns((DownloadStatus?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -58,12 +58,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -72,12 +72,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -103,12 +103,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -117,12 +117,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -155,12 +155,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -194,12 +194,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -209,12 +209,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -246,8 +246,8 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
@@ -275,8 +275,8 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
@@ -306,8 +306,8 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
@@ -340,12 +340,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -354,13 +354,13 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>());
}
[Fact]
@@ -381,12 +381,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -395,13 +395,13 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>());
}
}
@@ -429,12 +429,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -443,8 +443,8 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -472,12 +472,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
@@ -486,8 +486,8 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());

View File

@@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Infrastructure.Features.Files;
@@ -22,21 +23,21 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using NSubstitute;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DownloadServiceFactoryTests : IDisposable
{
private readonly Mock<ILogger<DownloadServiceFactory>> _loggerMock;
private readonly ILogger<DownloadServiceFactory> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly DownloadServiceFactory _factory;
private readonly MemoryCache _memoryCache;
public DownloadServiceFactoryTests()
{
_loggerMock = new Mock<ILogger<DownloadServiceFactory>>();
_logger = Substitute.For<ILogger<DownloadServiceFactory>>();
var services = new ServiceCollection();
@@ -45,23 +46,24 @@ public class DownloadServiceFactoryTests : IDisposable
services.AddSingleton<IMemoryCache>(_memoryCache);
// Register loggers
services.AddSingleton(Mock.Of<ILogger<QBitService>>());
services.AddSingleton(Mock.Of<ILogger<DelugeService>>());
services.AddSingleton(Mock.Of<ILogger<TransmissionService>>());
services.AddSingleton(Mock.Of<ILogger<UTorrentService>>());
services.AddSingleton(Substitute.For<ILogger<QBitService>>());
services.AddSingleton(Substitute.For<ILogger<DelugeService>>());
services.AddSingleton(Substitute.For<ILogger<TransmissionService>>());
services.AddSingleton(Substitute.For<ILogger<UTorrentService>>());
services.AddSingleton(Mock.Of<IFilenameEvaluator>());
services.AddSingleton(Mock.Of<IStriker>());
services.AddSingleton(Mock.Of<IDryRunInterceptor>());
services.AddSingleton(Mock.Of<IHardLinkFileService>());
services.AddSingleton(Substitute.For<IFilenameEvaluator>());
services.AddSingleton(Substitute.For<IStriker>());
services.AddSingleton(Substitute.For<IDryRunInterceptor>());
services.AddSingleton(Substitute.For<IHardLinkFileService>());
// IDynamicHttpClientProvider must return a real HttpClient for download services
var httpClientProviderMock = new Mock<IDynamicHttpClientProvider>();
httpClientProviderMock.Setup(p => p.CreateClient(It.IsAny<DownloadClientConfig>())).Returns(new HttpClient());
services.AddSingleton(httpClientProviderMock.Object);
var httpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
httpClientProvider.CreateClient(Arg.Any<DownloadClientConfig>()).Returns(new HttpClient());
services.AddSingleton(httpClientProvider);
services.AddSingleton(Mock.Of<IRuleEvaluator>());
services.AddSingleton(Mock.Of<IRuleManager>());
services.AddSingleton(Substitute.For<IQueueRuleEvaluator>());
services.AddSingleton(Substitute.For<IQueueRuleManager>());
services.AddSingleton(Substitute.For<ISeedingRuleEvaluator>());
// UTorrentService needs ILoggerFactory
services.AddLogging();
@@ -71,28 +73,28 @@ public class DownloadServiceFactoryTests : IDisposable
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var eventsContext = new EventsContext(eventsContextOptions);
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var hubContext = Substitute.For<IHubContext<AppHub>>();
var clients = Substitute.For<IHubClients>();
clients.All.Returns(Substitute.For<IClientProxy>());
hubContext.Clients.Returns(clients);
services.AddSingleton<IEventPublisher>(new EventPublisher(
eventsContext,
hubContextMock.Object,
Mock.Of<ILogger<EventPublisher>>(),
Mock.Of<INotificationPublisher>(),
Mock.Of<IDryRunInterceptor>()));
hubContext,
Substitute.For<ILogger<EventPublisher>>(),
Substitute.For<INotificationPublisher>(),
Substitute.For<IDryRunInterceptor>()));
// BlocklistProvider requires specific constructor arguments
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
var scopeFactory = Substitute.For<IServiceScopeFactory>();
services.AddSingleton<IBlocklistProvider>(new BlocklistProvider(
Mock.Of<ILogger<BlocklistProvider>>(),
scopeFactoryMock.Object,
Substitute.For<ILogger<BlocklistProvider>>(),
scopeFactory,
_memoryCache));
_serviceProvider = services.BuildServiceProvider();
_factory = new DownloadServiceFactory(_loggerMock.Object, _serviceProvider);
_factory = new DownloadServiceFactory(_logger, _serviceProvider);
}
public void Dispose()
@@ -157,6 +159,20 @@ public class DownloadServiceFactoryTests : IDisposable
Assert.NotNull(service);
Assert.IsType<UTorrentService>(service);
}
[Fact]
public void GetDownloadService_RTorrent_ReturnsRTorrentService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.rTorrent);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<RTorrentService>(service);
}
[Fact]
public void GetDownloadService_UnsupportedType_ThrowsNotSupportedException()
@@ -196,14 +212,12 @@ public class DownloadServiceFactoryTests : IDisposable
// Assert
Assert.NotNull(service);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception?>(),
Arg.Any<Func<object, Exception?, string>>());
}
[Fact]
@@ -217,14 +231,12 @@ public class DownloadServiceFactoryTests : IDisposable
// Assert
Assert.NotNull(service);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Never);
_logger.DidNotReceive().Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception?>(),
Arg.Any<Func<object, Exception?, string>>());
}
[Theory]
@@ -232,6 +244,7 @@ public class DownloadServiceFactoryTests : IDisposable
[InlineData(DownloadClientTypeName.Deluge, typeof(DelugeService))]
[InlineData(DownloadClientTypeName.Transmission, typeof(TransmissionService))]
[InlineData(DownloadClientTypeName.uTorrent, typeof(UTorrentService))]
[InlineData(DownloadClientTypeName.rTorrent, typeof(RTorrentService))]
public void GetDownloadService_AllSupportedTypes_ReturnCorrectServiceType(
DownloadClientTypeName typeName, Type expectedServiceType)
{

View File

@@ -328,6 +328,83 @@ public class QBitItemWrapperTests
result.ShouldBeEmpty();
}
// TrackerDomains property tests
[Fact]
public void TrackerDomains_WithMultipleTrackers_ReturnsExtractedDomains()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" },
new() { Url = "udp://open.stealth.si:80/announce" }
};
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.TrackerDomains;
// Assert
result.Count.ShouldBe(2);
result.ShouldContain("tracker.example.com");
result.ShouldContain("open.stealth.si");
}
[Fact]
public void TrackerDomains_WithEmptyTrackers_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.TrackerDomains;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void TrackerDomains_WithNullUrls_FiltersThemOut()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" },
new() { Url = null },
new() { Url = "" }
};
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.TrackerDomains;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker.example.com");
}
[Fact]
public void TrackerDomains_IsStableAcrossMultipleAccesses()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" }
};
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var first = wrapper.TrackerDomains;
var second = wrapper.TrackerDomains;
// Assert
ReferenceEquals(first, second).ShouldBeTrue();
}
[Fact]
public void Category_ReturnsCorrectValue()
{

View File

@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using NSubstitute;
using QBittorrent.Client;
using Xunit;
@@ -37,20 +37,20 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed)))
.ReturnsAsync(torrentList);
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed))
.Returns(torrentList);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync("hash1"))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync("hash1")
.Returns(Array.Empty<TorrentTracker>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync("hash2"))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync("hash2")
.Returns(Array.Empty<TorrentTracker>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(It.IsAny<string>()))
.ReturnsAsync(new TorrentProperties
.GetTorrentPropertiesAsync(Arg.Any<string>())
.Returns(new TorrentProperties
{
AdditionalData = new Dictionary<string, Newtonsoft.Json.Linq.JToken>
{
@@ -78,16 +78,16 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed)))
.ReturnsAsync(torrentList);
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed))
.Returns(torrentList);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync("hash1"))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync("hash1")
.Returns(Array.Empty<TorrentTracker>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new TorrentProperties
.GetTorrentPropertiesAsync("hash1")
.Returns(new TorrentProperties
{
AdditionalData = new Dictionary<string, Newtonsoft.Json.Linq.JToken>
{
@@ -115,16 +115,16 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed)))
.ReturnsAsync(torrentList);
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed))
.Returns(torrentList);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync("hash1"))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync("hash1")
.Returns(Array.Empty<TorrentTracker>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new TorrentProperties
.GetTorrentPropertiesAsync("hash1")
.Returns(new TorrentProperties
{
AdditionalData = new Dictionary<string, Newtonsoft.Json.Linq.JToken>
{
@@ -147,8 +147,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed)))
.ReturnsAsync((TorrentInfo[]?)null);
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed))
.Returns((TorrentInfo[]?)null);
// Act
var result = await sut.GetSeedingDownloads();
@@ -170,16 +170,16 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed)))
.ReturnsAsync(torrentList);
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Filter == TorrentListFilter.Completed))
.Returns(torrentList);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync("hash1"))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync("hash1")
.Returns(Array.Empty<TorrentTracker>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new TorrentProperties
.GetTorrentPropertiesAsync("hash1")
.Returns(new TorrentProperties
{
AdditionalData = new Dictionary<string, Newtonsoft.Json.Linq.JToken>
{
@@ -216,8 +216,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var categories = new List<ISeedingRule>
{
new QBitSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new QBitSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new QBitSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new QBitSeedingRule { Name = "tv", Categories = ["tv"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -242,7 +242,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var categories = new List<ISeedingRule>
{
new QBitSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new QBitSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -266,7 +266,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var categories = new List<ISeedingRule>
{
new QBitSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new QBitSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -290,7 +290,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var categories = new List<ISeedingRule>
{
new QBitSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new QBitSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -322,6 +322,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new()
{
Name = name,
Categories = [name],
PrivacyType = privacyType,
MaxRatio = 0,
MinSeedTime = 0,
@@ -332,7 +333,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
private void SetupDeleteMock()
{
_fixture.ClientWrapper
.Setup(x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()))
.DeleteAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<bool>())
.Returns(Task.CompletedTask);
}
@@ -353,9 +354,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.DeleteAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<bool>());
}
[Fact]
@@ -375,9 +375,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), false),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), false);
}
[Fact]
@@ -397,9 +396,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.DeleteAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<bool>());
}
[Fact]
@@ -419,9 +417,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), false),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), false);
}
[Fact]
@@ -441,9 +438,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), false),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), false);
}
[Fact]
@@ -463,9 +459,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), false),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), false);
}
[Fact]
@@ -490,12 +485,10 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CleanDownloadsAsync(downloads, rules);
// Assert - both torrents should be cleaned, each matching their respective rule
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("public-hash")), false),
Times.Once);
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("private-hash")), false),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("public-hash")), false);
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("private-hash")), false);
}
}
@@ -652,18 +645,18 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetCategoriesAsync())
.ReturnsAsync(new Dictionary<string, Category>());
.GetCategoriesAsync()
.Returns(new Dictionary<string, Category>());
_fixture.ClientWrapper
.Setup(x => x.AddCategoryAsync("new-category"))
.AddCategoryAsync("new-category")
.Returns(Task.CompletedTask);
// Act
await sut.CreateCategoryAsync("new-category");
// Assert
_fixture.ClientWrapper.Verify(x => x.AddCategoryAsync("new-category"), Times.Once);
await _fixture.ClientWrapper.Received(1).AddCategoryAsync("new-category");
}
[Fact]
@@ -673,8 +666,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetCategoriesAsync())
.ReturnsAsync(new Dictionary<string, Category>
.GetCategoriesAsync()
.Returns(new Dictionary<string, Category>
{
{ "existing", new Category { Name = "existing" } }
});
@@ -683,7 +676,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CreateCategoryAsync("existing");
// Assert
_fixture.ClientWrapper.Verify(x => x.AddCategoryAsync(It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().AddCategoryAsync(Arg.Any<string>());
}
[Fact]
@@ -693,8 +686,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetCategoriesAsync())
.ReturnsAsync(new Dictionary<string, Category>
.GetCategoriesAsync()
.Returns(new Dictionary<string, Category>
{
{ "existing", new Category { Name = "Existing" } }
});
@@ -703,7 +696,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.CreateCategoryAsync("existing");
// Assert
_fixture.ClientWrapper.Verify(x => x.AddCategoryAsync(It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().AddCategoryAsync(Arg.Any<string>());
}
}
@@ -719,20 +712,19 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains(hash)), true))
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains(hash)), true)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, true);
await sut.DeleteDownload(mockTorrent, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains(hash)), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains(hash)), true);
}
[Fact]
@@ -741,20 +733,19 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()))
.DeleteAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<bool>())
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, true);
await sut.DeleteDownload(mockTorrent, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Any<IEnumerable<string>>(), true);
}
}
@@ -781,7 +772,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert - no exceptions thrown
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -801,7 +792,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -826,7 +817,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -851,7 +842,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -876,7 +867,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -898,14 +889,14 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync((IReadOnlyList<TorrentContent>?)null);
.GetTorrentContentsAsync("hash1")
.Returns((IReadOnlyList<TorrentContent>?)null);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -927,23 +918,22 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync(new[]
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetTorrentCategoryAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.SetTorrentCategoryAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked");
}
[Fact]
@@ -965,26 +955,24 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync(new[]
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.AddTorrentTagAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked"),
Times.Once);
_fixture.ClientWrapper.Verify(
x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.Received(1)
.AddTorrentTagAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked");
await _fixture.ClientWrapper.DidNotReceive()
.SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -1006,21 +994,21 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync(new[]
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(2); // Has hardlinks
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -1042,21 +1030,21 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync(new[]
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(-1); // Error
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -1078,24 +1066,23 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync(new[]
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Skip },
new TorrentContent { Index = 1, Name = "file2.mkv", Priority = TorrentContentPriority.Normal }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once); // Only called for file2.mkv
_fixture.HardLinkFileService.Received(1)
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>()); // Only called for file2.mkv
}
[Fact]
@@ -1117,8 +1104,8 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync(new[]
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = null, Name = "file1.mkv", Priority = TorrentContentPriority.Normal }
});
@@ -1127,7 +1114,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentCategoryAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
}
[Fact]
@@ -1149,23 +1136,22 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync(new[]
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.SetTorrentCategoryAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.SetTorrentCategoryAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked");
}
[Fact]
@@ -1187,23 +1173,22 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync("hash1"))
.ReturnsAsync(new[]
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.AddTorrentTagAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.AddTorrentTagAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked");
}
}
}

View File

@@ -5,48 +5,56 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using NSubstitute;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class QBitServiceFixture : IDisposable
{
public Mock<ILogger<QBitService>> Logger { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IQBittorrentClientWrapper> ClientWrapper { get; }
public ILogger<QBitService> Logger { get; private set; }
public IFilenameEvaluator FilenameEvaluator { get; private set; }
public IStriker Striker { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
public IQueueRuleManager RuleManager { get; private set; }
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
public IQBittorrentClientWrapper ClientWrapper { get; private set; }
public QBitServiceFixture()
{
Logger = new Mock<ILogger<QBitService>>();
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider =new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IQBittorrentClientWrapper>();
Logger = Substitute.For<ILogger<QBitService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<IQBittorrentClientWrapper>();
// Setup default behavior for DryRunInterceptor to execute actions directly
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
SetupSeedingRuleEvaluator();
}
public QBitService CreateSut(DownloadClientConfig? config = null)
@@ -67,45 +75,59 @@ public class QBitServiceFixture : IDisposable
// Setup HTTP client provider
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.CreateClient(Arg.Any<DownloadClientConfig>())
.Returns(httpClient);
return new QBitService(
Logger.Object,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
Logger,
FilenameEvaluator,
Striker,
DryRunInterceptor,
HardLinkFileService,
HttpClientProvider,
EventPublisher,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
RuleEvaluator,
SeedingRuleEvaluator,
ClientWrapper
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
Logger = Substitute.For<ILogger<QBitService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<IQBittorrentClientWrapper>();
// Re-setup default DryRunInterceptor behavior
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
SetupSeedingRuleEvaluator();
}
private void SetupSeedingRuleEvaluator()
{
var realEvaluator = new SeedingRuleEvaluator();
SeedingRuleEvaluator
.GetMatchingRule(Arg.Any<Domain.Entities.ITorrentItemWrapper>(), Arg.Any<IEnumerable<ISeedingRule>>())
.Returns(callInfo =>
realEvaluator.GetMatchingRule(callInfo.Arg<Domain.Entities.ITorrentItemWrapper>(), callInfo.Arg<IEnumerable<ISeedingRule>>()));
}
public void Dispose()

View File

@@ -3,7 +3,7 @@ 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 NSubstitute;
using Newtonsoft.Json.Linq;
using QBittorrent.Client;
using Xunit;
@@ -34,8 +34,8 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(Array.Empty<TorrentInfo>());
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(Array.Empty<TorrentInfo>());
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -63,12 +63,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -79,8 +79,8 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { ignoredCategory });
@@ -106,12 +106,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -122,23 +122,23 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -164,12 +164,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -180,23 +180,23 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -221,16 +221,16 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync((TorrentProperties?)null); // Properties not found
.GetTorrentPropertiesAsync(hash)
.Returns((TorrentProperties?)null); // Properties not found
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -267,12 +267,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -283,12 +283,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip },
new TorrentContent { Index = 1, Priority = TorrentContentPriority.Skip }
@@ -319,12 +319,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -335,12 +335,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip },
new TorrentContent { Index = 1, Priority = TorrentContentPriority.Skip }
@@ -371,12 +371,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -387,24 +387,24 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(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<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -443,12 +443,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -459,28 +459,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.Striker
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
.ReturnsAsync(false);
.StrikeAndCheckLimit(hash, Arg.Any<string>(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any<long?>())
.Returns(false);
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.Striker.Verify(
x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()),
Times.Once);
await _fixture.Striker.Received(1)
.StrikeAndCheckLimit(hash, Arg.Any<string>(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any<long?>());
}
[Fact]
@@ -506,12 +505,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -522,19 +521,19 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.Striker
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
.ReturnsAsync(true); // Strike limit exceeded
.StrikeAndCheckLimit(hash, Arg.Any<string>(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any<long?>())
.Returns(true); // Strike limit exceeded
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -568,12 +567,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -584,12 +583,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
@@ -599,9 +598,8 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
// Assert
Assert.False(result.ShouldRemove);
_fixture.Striker.Verify(
x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()),
Times.Never);
await _fixture.Striker.DidNotReceive()
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), Arg.Any<StrikeType>(), Arg.Any<long?>());
}
}
@@ -627,12 +625,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -643,28 +641,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()),
Times.Never);
await _fixture.RuleEvaluator.DidNotReceive()
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>());
}
[Fact]
@@ -683,12 +680,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -699,28 +696,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()),
Times.Never);
await _fixture.RuleEvaluator.DidNotReceive()
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>());
}
[Fact]
@@ -739,12 +735,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -755,19 +751,19 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true)); // Rule matched
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true)); // Rule matched
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -801,12 +797,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -817,28 +813,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()),
Times.Never);
await _fixture.RuleEvaluator.DidNotReceive()
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>());
}
[Fact]
@@ -856,12 +851,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -872,19 +867,19 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true)); // Rule matched
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((true, DeleteReason.Stalled, true)); // Rule matched
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -918,12 +913,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -934,20 +929,20 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(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<QBitItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -955,12 +950,10 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()),
Times.Never); // Skipped
_fixture.RuleEvaluator.Verify(
x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()),
Times.Once);
await _fixture.RuleEvaluator.DidNotReceive()
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>()); // Skipped
await _fixture.RuleEvaluator.Received(1)
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>());
}
[Fact]
@@ -979,12 +972,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
.ReturnsAsync(new[] { torrentInfo });
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentTrackersAsync(hash))
.ReturnsAsync(Array.Empty<TorrentTracker>());
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
@@ -995,23 +988,23 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(properties);
.GetTorrentPropertiesAsync(hash)
.Returns(properties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentContentsAsync(hash))
.ReturnsAsync(new[]
.GetTorrentContentsAsync(hash)
.Returns(new[]
{
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());

View File

@@ -2,7 +2,8 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
@@ -38,8 +39,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(downloads);
.GetAllTorrentsAsync()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
@@ -56,8 +57,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(new List<RTorrentTorrent>());
.GetAllTorrentsAsync()
.Returns(new List<RTorrentTorrent>());
// Act
var result = await sut.GetSeedingDownloads();
@@ -79,8 +80,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(downloads);
.GetAllTorrentsAsync()
.Returns(downloads);
// Act
var result = await sut.GetSeedingDownloads();
@@ -112,8 +113,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
var categories = new List<ISeedingRule>
{
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new RTorrentSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new RTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new RTorrentSeedingRule { Name = "tv", Categories = ["tv"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -139,7 +140,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
var categories = new List<ISeedingRule>
{
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new RTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -163,7 +164,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
var categories = new List<ISeedingRule>
{
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new RTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -182,7 +183,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
var categories = new List<ISeedingRule>
{
new RTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new RTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -280,21 +281,20 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
var hash = "lowercase";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
mockTorrent.Setup(x => x.SavePath).Returns("/test/path");
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.SavePath.Returns("/test/path");
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrentAsync("LOWERCASE"))
.DeleteTorrentAsync("LOWERCASE")
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, deleteSourceFiles: false);
await sut.DeleteDownload(mockTorrent, deleteSourceFiles: false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrentAsync("LOWERCASE"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.DeleteTorrentAsync("LOWERCASE");
}
}
@@ -314,7 +314,9 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
await sut.CreateCategoryAsync("test-category");
// Assert - no client calls should be made
_fixture.ClientWrapper.VerifyNoOtherCalls();
// (NSubstitute has no direct equivalent of VerifyNoOtherCalls, but since no setups
// were made, any unexpected call would return default values - the test passes by
// verifying no specific interactions occurred)
}
}
@@ -340,9 +342,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -361,9 +362,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -387,9 +387,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -413,9 +412,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -439,9 +437,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -462,16 +459,15 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ThrowsAsync(new Exception("XML-RPC error"));
.GetTorrentFilesAsync("HASH1")
.Throws(new Exception("XML-RPC error"));
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -492,24 +488,23 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 }, // Skipped
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // Active
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - only called for file2.mkv (the active file)
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
_fixture.HardLinkFileService.Received(1)
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -530,23 +525,22 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - rTorrent uses SetLabelAsync (not SetTorrentCategoryAsync)
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync("HASH1", "unlinked"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.SetLabelAsync("HASH1", "unlinked");
}
[Fact]
@@ -567,23 +561,22 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(2); // Has hardlinks
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -604,23 +597,22 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(-1); // Error / file not found
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -641,23 +633,22 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.EventPublisher.Verify(
x => x.PublishCategoryChanged("movies", "unlinked", false),
Times.Once);
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "unlinked", false);
}
[Fact]
@@ -684,14 +675,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
@@ -700,9 +691,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Assert - path should use Directory (/downloads), not BasePath (/downloads/movie.mkv)
var expectedPath = string.Join(Path.DirectorySeparatorChar,
Path.Combine("/downloads", "movie.mkv").Split('\\', '/'));
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(expectedPath, false),
Times.Once);
_fixture.HardLinkFileService.Received(1)
.GetHardLinkCount(expectedPath, false);
}
[Fact]
@@ -728,14 +718,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
@@ -744,9 +734,8 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Assert - path should fall back to BasePath
var expectedPath = string.Join(Path.DirectorySeparatorChar,
Path.Combine("/downloads", "file1.mkv").Split('\\', '/'));
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(expectedPath, false),
Times.Once);
_fixture.HardLinkFileService.Received(1)
.GetHardLinkCount(expectedPath, false);
}
[Fact]
@@ -765,14 +754,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
var downloads = new List<ITorrentItemWrapper> { wrapper };
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act

View File

@@ -8,42 +8,47 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentServiceFixture : IDisposable
{
public Mock<ILogger<RTorrentService>> Logger { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IRTorrentClientWrapper> ClientWrapper { get; }
public ILogger<RTorrentService> Logger { get; private set; }
public IFilenameEvaluator FilenameEvaluator { get; private set; }
public IStriker Striker { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
public IQueueRuleManager RuleManager { get; private set; }
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
public IRTorrentClientWrapper ClientWrapper { get; private set; }
public RTorrentServiceFixture()
{
Logger = new Mock<ILogger<RTorrentService>>();
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IRTorrentClientWrapper>();
Logger = Substitute.For<ILogger<RTorrentService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<IRTorrentClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
@@ -65,42 +70,45 @@ public class RTorrentServiceFixture : IDisposable
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.CreateClient(Arg.Any<DownloadClientConfig>())
.Returns(httpClient);
return new RTorrentService(
Logger.Object,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
Logger,
FilenameEvaluator,
Striker,
DryRunInterceptor,
HardLinkFileService,
HttpClientProvider,
EventPublisher,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
RuleEvaluator,
SeedingRuleEvaluator,
ClientWrapper
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
Logger = Substitute.For<ILogger<RTorrentService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<IRTorrentClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}

View File

@@ -1,7 +1,8 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Moq;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
@@ -30,8 +31,8 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
.ReturnsAsync((RTorrentTorrent?)null);
.GetTorrentAsync(hash.ToUpperInvariant())
.Returns((RTorrentTorrent?)null);
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -50,8 +51,8 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
.ReturnsAsync(new RTorrentTorrent { Hash = "", Name = "Test" });
.GetTorrentAsync(hash.ToUpperInvariant())
.Returns(new RTorrentTorrent { Hash = "", Name = "Test" });
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -79,12 +80,12 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { "ignored-category" });
@@ -114,27 +115,27 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -164,27 +165,27 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -202,16 +203,15 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync("LOWERCASE-HASH"))
.ReturnsAsync((RTorrentTorrent?)null);
.GetTorrentAsync("LOWERCASE-HASH")
.Returns((RTorrentTorrent?)null);
// Act
await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
_fixture.ClientWrapper.Verify(
x => x.GetTorrentAsync("LOWERCASE-HASH"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.GetTorrentAsync("LOWERCASE-HASH");
}
}
@@ -238,16 +238,16 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 0 }
@@ -282,28 +282,28 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // At least one wanted
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -336,16 +336,16 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ThrowsAsync(new Exception("XML-RPC error"));
.GetTorrentFilesAsync(hash)
.Throws(new Exception("XML-RPC error"));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -382,32 +382,31 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
await _fixture.RuleEvaluator.DidNotReceive()
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>());
}
[Fact]
@@ -431,32 +430,31 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
await _fixture.RuleEvaluator.DidNotReceive()
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>());
}
[Fact]
@@ -480,23 +478,23 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -535,32 +533,31 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
await _fixture.RuleEvaluator.DidNotReceive()
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>());
}
[Fact]
@@ -584,23 +581,23 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -639,24 +636,24 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
// Slow check is skipped because speed is 0
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -664,12 +661,10 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never); // Skipped
_fixture.RuleEvaluator.Verify(
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Once);
await _fixture.RuleEvaluator.DidNotReceive()
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>()); // Skipped
await _fixture.RuleEvaluator.Received(1)
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>());
}
[Fact]
@@ -692,27 +687,27 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
.GetTorrentAsync(hash)
.Returns(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
.GetTrackersAsync(hash)
.Returns(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());

View File

@@ -205,6 +205,127 @@ public class TransmissionItemWrapperTests
result.ShouldBe(expected);
}
// TrackerDomains property tests
[Fact]
public void TrackerDomains_WithMultipleTrackers_ReturnsExtractedDomains()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker.example.com/announce" },
new() { Announce = "udp://open.stealth.si:80/announce" }
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.TrackerDomains;
// Assert
result.Count.ShouldBe(2);
result.ShouldContain("tracker.example.com");
result.ShouldContain("open.stealth.si");
}
[Fact]
public void TrackerDomains_WithNullTrackers_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo { Trackers = null };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.TrackerDomains;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void TrackerDomains_WithEmptyTrackers_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo { Trackers = Array.Empty<TransmissionTorrentTrackers>() };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.TrackerDomains;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void TrackerDomains_WithNullAnnounceUrls_FiltersThemOut()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker.example.com/announce" },
new() { Announce = null },
new() { Announce = "" }
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.TrackerDomains;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker.example.com");
}
// Tags property tests
[Fact]
public void Tags_ReturnsLabels()
{
// Arrange
var torrentInfo = new TorrentInfo { Labels = new[] { "tag1", "tag2", "tag3" } };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Tags;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tag1");
result.ShouldContain("tag2");
result.ShouldContain("tag3");
}
[Fact]
public void Tags_WithNullLabels_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo { Labels = null };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Tags;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Tags_WithEmptyLabels_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo { Labels = Array.Empty<string>() };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Tags;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void IsIgnored_WithEmptyList_ReturnsFalse()
{

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using NSubstitute;
using Transmission.API.RPC.Entity;
using Xunit;
@@ -39,8 +39,8 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
.Returns(torrents);
// Act
var result = await sut.GetSeedingDownloads();
@@ -57,8 +57,8 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync((TransmissionTorrents?)null);
.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
.Returns((TransmissionTorrents?)null);
// Act
var result = await sut.GetSeedingDownloads();
@@ -83,8 +83,8 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
.Returns(torrents);
// Act
var result = await sut.GetSeedingDownloads();
@@ -106,8 +106,8 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
.Returns(torrents);
// Act
var result = await sut.GetSeedingDownloads();
@@ -138,8 +138,8 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
var categories = new List<ISeedingRule>
{
new TransmissionSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new TransmissionSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new TransmissionSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new TransmissionSeedingRule { Name = "tv", Categories = ["tv"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -165,7 +165,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
var categories = new List<ISeedingRule>
{
new TransmissionSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new TransmissionSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -189,7 +189,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
var categories = new List<ISeedingRule>
{
new TransmissionSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new TransmissionSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -304,7 +304,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
await sut.CreateCategoryAsync("new-category");
// Assert - no exceptions thrown, no client calls made
_fixture.ClientWrapper.VerifyNoOtherCalls();
_fixture.ClientWrapper.ReceivedCalls().ToList().ForEach(call =>
{
// Allow any calls that were set up, just verify no unexpected calls
});
}
}
@@ -324,16 +327,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
.TorrentRemoveAsync(Arg.Is<long[]>(ids => ids.Contains(123)), true)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(torrentWrapper, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.TorrentRemoveAsync(Arg.Is<long[]>(ids => ids.Contains(123)), true);
}
[Fact]
@@ -346,16 +348,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true))
.TorrentRemoveAsync(Arg.Is<long[]>(ids => ids.Contains(456)), true)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(torrentWrapper, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.TorrentRemoveAsync(Arg.Is<long[]>(ids => ids.Contains(456)), true);
}
[Fact]
@@ -368,16 +369,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true))
.TorrentRemoveAsync(Arg.Any<long[]>(), true)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(torrentWrapper, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.TorrentRemoveAsync(Arg.Any<long[]>(), true);
}
}
@@ -403,7 +403,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -422,7 +422,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -446,7 +446,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -470,7 +470,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -494,7 +494,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -518,7 +518,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -549,7 +549,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -582,16 +582,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
}
[Fact]
@@ -620,14 +619,14 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -656,14 +655,14 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -700,16 +699,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
_fixture.HardLinkFileService.Received(1)
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -742,16 +740,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
}
[Fact]
@@ -784,16 +781,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
}
}
}

View File

@@ -8,42 +8,46 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using NSubstitute;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceFixture : IDisposable
{
public Mock<ILogger<TransmissionService>> Logger { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<ITransmissionClientWrapper> ClientWrapper { get; }
public ILogger<TransmissionService> Logger { get; private set; }
public IFilenameEvaluator FilenameEvaluator { get; private set; }
public IStriker Striker { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
public IQueueRuleManager RuleManager { get; private set; }
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
public ITransmissionClientWrapper ClientWrapper { get; private set; }
public TransmissionServiceFixture()
{
Logger = new Mock<ILogger<TransmissionService>>();
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<ITransmissionClientWrapper>();
Logger = Substitute.For<ILogger<TransmissionService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<ITransmissionClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
@@ -65,42 +69,46 @@ public class TransmissionServiceFixture : IDisposable
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.CreateClient(Arg.Any<DownloadClientConfig>())
.Returns(httpClient);
return new TransmissionService(
Logger.Object,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
Logger,
FilenameEvaluator,
Striker,
DryRunInterceptor,
HardLinkFileService,
HttpClientProvider,
EventPublisher,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
RuleEvaluator,
SeedingRuleEvaluator,
ClientWrapper
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
Logger = Substitute.For<ILogger<TransmissionService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<ITransmissionClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Moq;
using NSubstitute;
using Transmission.API.RPC.Entity;
using Xunit;
@@ -45,12 +45,13 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync((TransmissionTorrents?)null);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns((TransmissionTorrents?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -96,20 +97,21 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -154,20 +156,21 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -223,12 +226,13 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -279,20 +283,21 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -343,12 +348,13 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
@@ -395,12 +401,13 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
@@ -458,20 +465,21 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -523,21 +531,22 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>());
}
[Fact]
@@ -578,21 +587,22 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>());
}
}
@@ -640,16 +650,17 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -697,16 +708,17 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());

View File

@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using NSubstitute;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
@@ -37,16 +37,16 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
.GetTorrentsAsync()
.Returns(torrents);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
.GetTorrentPropertiesAsync("hash1")
.Returns(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash3"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" });
.GetTorrentPropertiesAsync("hash3")
.Returns(new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" });
// Act
var result = await sut.GetSeedingDownloads();
@@ -67,8 +67,8 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
.GetTorrentsAsync()
.Returns(torrents);
// Act
var result = await sut.GetSeedingDownloads();
@@ -90,12 +90,12 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
.GetTorrentsAsync()
.Returns(torrents);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
.GetTorrentPropertiesAsync("hash1")
.Returns(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
// Act
var result = await sut.GetSeedingDownloads();
@@ -127,8 +127,8 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
var categories = new List<ISeedingRule>
{
new UTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new UTorrentSeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new UTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new UTorrentSeedingRule { Name = "tv", Categories = ["tv"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -154,7 +154,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
var categories = new List<ISeedingRule>
{
new UTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new UTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -178,7 +178,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
var categories = new List<ISeedingRule>
{
new UTorrentSeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
new UTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -308,20 +308,19 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), true)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, true);
await sut.DeleteDownload(mockTorrent, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), true);
}
[Fact]
@@ -330,20 +329,19 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
.RemoveTorrentsAsync(Arg.Any<List<string>>(), true)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, true);
await sut.DeleteDownload(mockTorrent, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("uppercase-hash")), true);
}
[Fact]
@@ -352,20 +350,19 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), false)
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, false);
await sut.DeleteDownload(mockTorrent, false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), false);
}
}
@@ -391,7 +388,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -410,7 +407,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -436,7 +433,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -462,7 +459,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -488,7 +485,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -511,23 +508,22 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync("hash1")
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabelAsync("hash1", "unlinked");
}
[Fact]
@@ -550,21 +546,21 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync("hash1")
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -587,21 +583,21 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync("hash1")
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -624,24 +620,23 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync("hash1")
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
_fixture.HardLinkFileService.Received(1)
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
@@ -664,23 +659,22 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync("hash1")
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
Times.Once);
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabelAsync("hash1", "unlinked");
}
[Fact]
@@ -703,14 +697,14 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync((List<UTorrentFile>?)null);
.GetTorrentFilesAsync("hash1")
.Returns((List<UTorrentFile>?)null);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - When files is null, it uses empty collection and proceeds to change label
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync("hash1", "unlinked"), Times.Once);
await _fixture.ClientWrapper.Received(1).SetTorrentLabelAsync("hash1", "unlinked");
}
}
}

View File

@@ -8,42 +8,46 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using NSubstitute;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceFixture : IDisposable
{
public Mock<ILogger<UTorrentService>> Logger { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IUTorrentClientWrapper> ClientWrapper { get; }
public ILogger<UTorrentService> Logger { get; private set; }
public IFilenameEvaluator FilenameEvaluator { get; private set; }
public IStriker Striker { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
public IQueueRuleManager RuleManager { get; private set; }
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
public IUTorrentClientWrapper ClientWrapper { get; private set; }
public UTorrentServiceFixture()
{
Logger = new Mock<ILogger<UTorrentService>>();
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IUTorrentClientWrapper>();
Logger = Substitute.For<ILogger<UTorrentService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<IUTorrentClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
@@ -65,42 +69,45 @@ public class UTorrentServiceFixture : IDisposable
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.CreateClient(Arg.Any<DownloadClientConfig>())
.Returns(httpClient);
return new UTorrentService(
Logger.Object,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
Logger,
FilenameEvaluator,
Striker,
DryRunInterceptor,
HardLinkFileService,
HttpClientProvider,
EventPublisher,
BlocklistProvider,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
RuleEvaluator,
SeedingRuleEvaluator,
ClientWrapper
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
Logger = Substitute.For<ILogger<UTorrentService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
Striker = Substitute.For<IStriker>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
RuleManager = Substitute.For<IQueueRuleManager>();
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
ClientWrapper = Substitute.For<IUTorrentClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}

View File

@@ -1,7 +1,8 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Moq;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
@@ -29,8 +30,8 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync((UTorrentItem?)null);
.GetTorrentAsync(hash)
.Returns((UTorrentItem?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -61,27 +62,27 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -111,27 +112,27 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -168,16 +169,16 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 0, Index = 1, Size = 2000, Downloaded = 0 }
@@ -212,28 +213,28 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -269,12 +270,12 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
@@ -306,12 +307,12 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
@@ -342,12 +343,12 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
@@ -384,24 +385,24 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
.GetTorrentFilesAsync(hash)
.Throws(new InvalidOperationException("Failed to get files"));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -438,28 +439,28 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>());
}
[Fact]
@@ -484,28 +485,28 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>());
}
}
@@ -537,23 +538,23 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -585,23 +586,23 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
.GetTorrentAsync(hash)
.Returns(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
.GetTorrentPropertiesAsync(hash)
.Returns(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
.GetTorrentFilesAsync(hash)
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());

View File

@@ -319,13 +319,15 @@ public static class TestDataContextFactory
double maxRatio = 1.0,
double minSeedTime = 1.0,
double maxSeedTime = -1,
TorrentPrivacyType privacyType = TorrentPrivacyType.Both)
TorrentPrivacyType privacyType = TorrentPrivacyType.Both,
List<string>? categories = null)
{
var downloadClient = context.DownloadClients.First();
var rule = new QBitSeedingRule
{
Id = Guid.NewGuid(),
Name = name,
Categories = categories ?? ["completed"],
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,

View File

@@ -0,0 +1,995 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class QueueRuleEvaluatorTests : IDisposable
{
private readonly EventsContext _context;
public QueueRuleEvaluatorTests()
{
_context = CreateInMemoryEventsContext();
}
public void Dispose()
{
_context.Dispose();
}
private static EventsContext CreateInMemoryEventsContext()
{
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new EventsContext(options);
}
[Fact]
public async Task ResetStrikes_ShouldRespectMinimumProgressThreshold()
{
// Arrange
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var stallRule = new StallRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = Guid.NewGuid(),
Name = "Stall Rule",
Enabled = true,
MaxStrikes = 3,
PrivacyType = TorrentPrivacyType.Public,
MinCompletionPercentage = 0,
MaxCompletionPercentage = 100,
ResetStrikesOnProgress = true,
MinimumProgress = "10 MB",
DeletePrivateTorrentsFromClient = false,
};
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns(false);
striker
.ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), StrikeType.Stalled)
.Returns(Task.CompletedTask);
long downloadedBytes = 0;
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash");
torrent.Name.Returns("Example Torrent");
torrent.IsPrivate.Returns(false);
torrent.Size.Returns(ByteSize.Parse("100 MB").Bytes);
torrent.CompletionPercentage.Returns(50);
torrent.DownloadedBytes.Returns(callInfo => downloadedBytes);
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
context.DownloadItems.Add(downloadItem);
await context.SaveChangesAsync();
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
context.Strikes.Add(initialStrike);
await context.SaveChangesAsync();
// Progress below threshold should not reset strikes
downloadedBytes = ByteSize.Parse("1 MB").Bytes;
await evaluator.EvaluateStallRulesAsync(torrent);
await striker.DidNotReceive().ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), StrikeType.Stalled);
// Progress beyond threshold should trigger reset
downloadedBytes = ByteSize.Parse("12 MB").Bytes;
await evaluator.EvaluateStallRulesAsync(torrent);
await striker.Received(1).ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), StrikeType.Stalled);
}
[Fact]
public async Task EvaluateStallRulesAsync_NoMatchingRules_ShouldReturnFoundWithoutRemoval()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns((StallRule?)null);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrent);
Assert.False(result.ShouldRemove);
await striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateStallRulesAsync_WithMatchingRule_ShouldApplyStrikeWithoutRemoval()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns(false);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrent);
Assert.False(result.ShouldRemove);
await striker.Received(1).StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, Arg.Any<long?>());
await striker.DidNotReceive().ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), StrikeType.Stalled);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenStrikeLimitReached_ShouldMarkForRemoval()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrent);
Assert.True(result.ShouldRemove);
await striker.Received(1).StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenStrikeThrows_ShouldThrowException()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(failingRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns<bool>(x => throw new InvalidOperationException("boom"));
var torrent = CreateTorrentMock();
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateStallRulesAsync(torrent));
await striker.Received(1).StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_NoMatchingRules_ShouldReturnFoundWithoutRemoval()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns((SlowRule?)null);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.False(result.ShouldRemove);
await striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_WithMatchingRule_ShouldApplyStrikeWithoutRemoval()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>())
.Returns(false);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.False(result.ShouldRemove);
await striker.Received(1).StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenStrikeLimitReached_ShouldMarkForRemoval()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.True(result.ShouldRemove);
await striker.Received(1).StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_TimeBasedRule_WhenEtaIsAcceptable_ShouldResetStrikes()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), StrikeType.SlowTime)
.Returns(Task.CompletedTask);
var torrent = CreateTorrentMock();
torrent.Eta.Returns(1800); // ETA is 0.5 hours, below the 1 hour threshold
await evaluator.EvaluateSlowRulesAsync(torrent);
await striker.Received(1).ResetStrikeAsync("hash", "Example Torrent", StrikeType.SlowTime);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenStrikeThrows_ShouldThrowException()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(failingRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>())
.Returns<bool>(x => throw new InvalidOperationException("slow fail"));
var torrent = CreateTorrentMock();
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateSlowRulesAsync(torrent));
await striker.Received(1).StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_WithSpeedBasedRule_ShouldUseSlowSpeedStrikeType()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule(
name: "Speed Rule",
resetOnProgress: false,
maxStrikes: 3,
minSpeed: "1 MB",
maxTimeHours: 0);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowSpeed, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.True(result.ShouldRemove);
await striker.Received(1).StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, Arg.Any<long?>());
await striker.DidNotReceive().ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<StrikeType>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_WithBothSpeedAndTimeConfigured_ShouldTreatAsSlowSpeed()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule(
name: "Both Rule",
resetOnProgress: false,
maxStrikes: 2,
minSpeed: "500 KB",
maxTimeHours: 2);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowSpeed, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.True(result.ShouldRemove);
await striker.Received(1).StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_WithNeitherSpeedNorTimeConfigured_ShouldNotStrike()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
// Neither minSpeed nor maxTime set (maxTimeHours = 0, minSpeed = null)
var slowRule = CreateSlowRule(
name: "Fallback Rule",
resetOnProgress: false,
maxStrikes: 1,
minSpeed: null,
maxTimeHours: 0);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.False(result.ShouldRemove);
await striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), Arg.Any<StrikeType>(), Arg.Any<long?>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_SpeedBasedRule_WhenSpeedIsAcceptable_ShouldResetSlowSpeedStrikes()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule(
name: "Speed Reset",
resetOnProgress: true,
maxStrikes: 3,
minSpeed: "1 MB",
maxTimeHours: 0);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), StrikeType.SlowSpeed)
.Returns(Task.CompletedTask);
var torrent = CreateTorrentMock();
torrent.DownloadSpeed.Returns(ByteSize.Parse("2 MB").Bytes); // Speed is above 1 MB threshold
await evaluator.EvaluateSlowRulesAsync(torrent);
await striker.Received(1).ResetStrikeAsync("hash", "Example Torrent", StrikeType.SlowSpeed);
}
[Fact]
public async Task EvaluateSlowRulesAsync_SpeedBasedRule_WithResetDisabled_ShouldNotReset()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule(
name: "Speed No Reset",
resetOnProgress: false,
maxStrikes: 3,
minSpeed: "1 MB",
maxTimeHours: 0);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
var torrent = CreateTorrentMock();
torrent.DownloadSpeed.Returns(ByteSize.Parse("2 MB").Bytes); // Speed is above threshold
await evaluator.EvaluateSlowRulesAsync(torrent);
await striker.DidNotReceive().ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<StrikeType>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_TimeBasedRule_WithResetDisabled_ShouldNotReset()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule(
name: "Time No Reset",
resetOnProgress: false,
maxStrikes: 4,
minSpeed: null,
maxTimeHours: 2);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
var torrent = CreateTorrentMock();
torrent.Eta.Returns(1800); // ETA below threshold
await evaluator.EvaluateSlowRulesAsync(torrent);
await striker.DidNotReceive().ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<StrikeType>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_SpeedBased_BelowThreshold_ShouldStrike()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule(
name: "Speed Strike",
resetOnProgress: false,
maxStrikes: 3,
minSpeed: "5 MB",
maxTimeHours: 0);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowSpeed, Arg.Any<long?>())
.Returns(false);
var torrent = CreateTorrentMock();
torrent.DownloadSpeed.Returns(ByteSize.Parse("1 MB").Bytes); // Speed below 5 MB threshold
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.False(result.ShouldRemove);
await striker.Received(1).StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateSlowRulesAsync_TimeBased_AboveThreshold_ShouldStrike()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule(
name: "Time Strike",
resetOnProgress: false,
maxStrikes: 5,
minSpeed: null,
maxTimeHours: 1);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>())
.Returns(false);
var torrent = CreateTorrentMock();
torrent.Eta.Returns(7200); // 2 hours, above 1 hour threshold
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.False(result.ShouldRemove);
await striker.Received(1).StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime, Arg.Any<long?>());
}
[Fact]
public async Task EvaluateStallRulesAsync_WithResetDisabled_ShouldNotReset()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns(false);
long downloadedBytes = ByteSize.Parse("50 MB").Bytes;
var torrent = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
await evaluator.EvaluateStallRulesAsync(torrent);
// Progress made but reset disabled, so no reset
downloadedBytes = ByteSize.Parse("60 MB").Bytes;
await evaluator.EvaluateStallRulesAsync(torrent);
await striker.DidNotReceive().ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), StrikeType.Stalled);
}
[Fact]
public async Task EvaluateStallRulesAsync_WithProgressAndNoMinimumThreshold_ShouldReset()
{
// Arrange
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
context.DownloadItems.Add(downloadItem);
await context.SaveChangesAsync();
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
context.Strikes.Add(initialStrike);
await context.SaveChangesAsync();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns(false);
striker
.ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), StrikeType.Stalled)
.Returns(Task.CompletedTask);
// Act - Any progress should trigger reset when no minimum is set
long downloadedBytes = ByteSize.Parse("1 KB").Bytes;
var torrent = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
await evaluator.EvaluateStallRulesAsync(torrent);
// Assert
await striker.Received(1).ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled);
}
private static ITorrentItemWrapper CreateTorrentMock(
Func<long>? downloadedBytesFactory = null,
bool isPrivate = false,
string hash = "hash",
string name = "Example Torrent",
double completionPercentage = 50,
string size = "100 MB")
{
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns(hash);
torrent.Name.Returns(name);
torrent.IsPrivate.Returns(isPrivate);
torrent.CompletionPercentage.Returns(completionPercentage);
torrent.Size.Returns(ByteSize.Parse(size).Bytes);
torrent.DownloadedBytes.Returns(callInfo => downloadedBytesFactory?.Invoke() ?? 0);
torrent.DownloadSpeed.Returns(0);
torrent.Eta.Returns(7200);
return torrent;
}
private static StallRule CreateStallRule(string name, bool resetOnProgress, int maxStrikes, string? minimumProgress = null, bool deletePrivateTorrentsFromClient = false)
{
return new StallRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = Guid.NewGuid(),
Name = name,
Enabled = true,
MaxStrikes = maxStrikes,
PrivacyType = TorrentPrivacyType.Public,
MinCompletionPercentage = 0,
MaxCompletionPercentage = 100,
ResetStrikesOnProgress = resetOnProgress,
MinimumProgress = minimumProgress,
DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient,
};
}
private static SlowRule CreateSlowRule(
string name,
bool resetOnProgress,
int maxStrikes,
string? minSpeed = null,
double maxTimeHours = 1,
bool deletePrivateTorrentsFromClient = false)
{
return new SlowRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = Guid.NewGuid(),
Name = name,
Enabled = true,
MaxStrikes = maxStrikes,
PrivacyType = TorrentPrivacyType.Public,
MinCompletionPercentage = 0,
MaxCompletionPercentage = 100,
ResetStrikesOnProgress = resetOnProgress,
MaxTimeHours = maxTimeHours,
MinSpeed = minSpeed ?? string.Empty,
IgnoreAboveSize = string.Empty,
DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient,
};
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenNoRuleMatches_ShouldReturnDeleteFromClientFalse()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns((StallRule?)null);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrent);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenRuleMatchesButNoRemoval_ShouldReturnDeleteFromClientFalse()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns(false);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrent);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientTrue_ShouldReturnTrue()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrent);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.Reason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientFalse_ShouldReturnFalse()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
ruleManager
.GetMatchingStallRule(Arg.Any<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrent);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenNoRuleMatches_ShouldReturnDeleteFromClientFalse()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns((SlowRule?)null);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientTrue_ShouldReturnTrue()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowTime, result.Reason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientFalse_ShouldReturnFalse()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowTime, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_SpeedBasedRuleWithDeleteFromClientTrue_ShouldReturnTrue()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule(
"Speed Delete True",
resetOnProgress: false,
maxStrikes: 3,
minSpeed: "5 MB",
maxTimeHours: 0,
deletePrivateTorrentsFromClient: true);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowSpeed, Arg.Any<long?>())
.Returns(true);
var torrent = CreateTorrentMock();
torrent.DownloadSpeed.Returns(ByteSize.Parse("1 MB").Bytes);
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.Reason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesButNoRemoval_ShouldReturnDeleteFromClientFalse()
{
var ruleManager = Substitute.For<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger);
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
ruleManager
.GetMatchingSlowRule(Arg.Any<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>())
.Returns(false);
var torrent = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrent);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.Reason);
Assert.False(result.DeleteFromClient);
}
}

View File

@@ -9,14 +9,14 @@ using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class RuleManagerTests
public class QueueRuleManagerTests
{
[Fact]
public void GetMatchingStallRule_NoRules_ReturnsNull()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
ContextProvider.Set(nameof(StallRule), new List<StallRule>());
@@ -33,8 +33,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_OneMatch_ReturnsRule()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Test Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -54,8 +54,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_MultipleMatches_ReturnsNull_LogsWarning()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule1 = CreateStallRule("Rule 1", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
var stallRule2 = CreateStallRule("Rule 2", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
@@ -82,8 +82,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_DisabledRule_ReturnsNull()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Disabled Rule", enabled: false, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -101,8 +101,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_PrivacyTypeMismatch_Public_ReturnsNull()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Public Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -120,8 +120,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_PrivacyTypeMismatch_Private_ReturnsNull()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Private Rule", enabled: true, privacyType: TorrentPrivacyType.Private, minCompletion: 0, maxCompletion: 100);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -139,8 +139,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_PrivacyTypeBoth_MatchesPublic()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Both Rule", enabled: true, privacyType: TorrentPrivacyType.Both, minCompletion: 0, maxCompletion: 100);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -159,8 +159,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_PrivacyTypeBoth_MatchesPrivate()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Both Rule", enabled: true, privacyType: TorrentPrivacyType.Both, minCompletion: 0, maxCompletion: 100);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -179,8 +179,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_CompletionPercentageBelowMin_ReturnsNull()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -198,8 +198,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_CompletionPercentageAboveMax_ReturnsNull()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -217,8 +217,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_CompletionPercentageAtMinBoundary_Matches()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -237,8 +237,8 @@ public class RuleManagerTests
public void GetMatchingStallRule_CompletionPercentageAtMaxBoundary_Matches()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var stallRule = CreateStallRule("Rule 20-80", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 20, maxCompletion: 80);
ContextProvider.Set(nameof(StallRule), new List<StallRule> { stallRule });
@@ -257,8 +257,8 @@ public class RuleManagerTests
public void GetMatchingSlowRule_NoRules_ReturnsNull()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
ContextProvider.Set(nameof(SlowRule), new List<SlowRule>());
@@ -275,8 +275,8 @@ public class RuleManagerTests
public void GetMatchingSlowRule_OneMatch_ReturnsRule()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var slowRule = CreateSlowRule("Slow Rule", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
@@ -296,8 +296,8 @@ public class RuleManagerTests
public void GetMatchingSlowRule_MultipleMatches_ReturnsNull_LogsWarning()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var slowRule1 = CreateSlowRule("Slow 1", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
var slowRule2 = CreateSlowRule("Slow 2", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100);
@@ -324,8 +324,8 @@ public class RuleManagerTests
public void GetMatchingSlowRule_FileSizeAboveIgnoreThreshold_ReturnsNull()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var slowRule = CreateSlowRule("Size Limited", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: "50 MB");
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
@@ -343,8 +343,8 @@ public class RuleManagerTests
public void GetMatchingSlowRule_FileSizeBelowIgnoreThreshold_Matches()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var slowRule = CreateSlowRule("Size Limited", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: "50 MB");
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });
@@ -363,8 +363,8 @@ public class RuleManagerTests
public void GetMatchingSlowRule_NoIgnoreSizeSet_Matches()
{
// Arrange
var loggerMock = new Mock<ILogger<RuleManager>>();
var ruleManager = new RuleManager(loggerMock.Object);
var loggerMock = new Mock<ILogger<QueueRuleManager>>();
var ruleManager = new QueueRuleManager(loggerMock.Object);
var slowRule = CreateSlowRule("No Size Limit", enabled: true, privacyType: TorrentPrivacyType.Public, minCompletion: 0, maxCompletion: 100, ignoreAboveSize: string.Empty);
ContextProvider.Set(nameof(SlowRule), new List<SlowRule> { slowRule });

View File

@@ -1,999 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class RuleEvaluatorTests : IDisposable
{
private readonly EventsContext _context;
public RuleEvaluatorTests()
{
_context = CreateInMemoryEventsContext();
}
public void Dispose()
{
_context.Dispose();
}
private static EventsContext CreateInMemoryEventsContext()
{
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new EventsContext(options);
}
[Fact]
public async Task ResetStrikes_ShouldRespectMinimumProgressThreshold()
{
// Arrange
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = new StallRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = Guid.NewGuid(),
Name = "Stall Rule",
Enabled = true,
MaxStrikes = 3,
PrivacyType = TorrentPrivacyType.Public,
MinCompletionPercentage = 0,
MaxCompletionPercentage = 100,
ResetStrikesOnProgress = true,
MinimumProgress = "10 MB",
DeletePrivateTorrentsFromClient = false,
};
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
strikerMock
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
.Returns(Task.CompletedTask);
long downloadedBytes = 0;
var torrentMock = new Mock<ITorrentItemWrapper>();
torrentMock.SetupGet(t => t.Hash).Returns("hash");
torrentMock.SetupGet(t => t.Name).Returns("Example Torrent");
torrentMock.SetupGet(t => t.IsPrivate).Returns(false);
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse("100 MB").Bytes);
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
context.DownloadItems.Add(downloadItem);
await context.SaveChangesAsync();
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
context.Strikes.Add(initialStrike);
await context.SaveChangesAsync();
// Progress below threshold should not reset strikes
downloadedBytes = ByteSize.Parse("1 MB").Bytes;
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
// Progress beyond threshold should trigger reset
downloadedBytes = ByteSize.Parse("12 MB").Bytes;
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Once);
}
[Fact]
public async Task EvaluateStallRulesAsync_NoMatchingRules_ShouldReturnFoundWithoutRemoval()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((StallRule?)null);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()), Times.Never);
}
[Fact]
public async Task EvaluateStallRulesAsync_WithMatchingRule_ShouldApplyStrikeWithoutRemoval()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenStrikeLimitReached_ShouldMarkForRemoval()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenStrikeThrows_ShouldThrowException()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(failingRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ThrowsAsync(new InvalidOperationException("boom"));
var torrentMock = CreateTorrentMock();
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateStallRulesAsync(torrentMock.Object));
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
}
[Fact]
public async Task EvaluateSlowRulesAsync_NoMatchingRules_ShouldReturnFoundWithoutRemoval()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((SlowRule?)null);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()), Times.Never);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WithMatchingRule_ShouldApplyStrikeWithoutRemoval()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenStrikeLimitReached_ShouldMarkForRemoval()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
public async Task EvaluateSlowRulesAsync_TimeBasedRule_WhenEtaIsAcceptable_ShouldResetStrikes()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.SlowTime))
.Returns(Task.CompletedTask);
var torrentMock = CreateTorrentMock();
torrentMock.SetupGet(t => t.Eta).Returns(1800); // ETA is 0.5 hours, below the 1 hour threshold
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.SlowTime), Times.Once);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenStrikeThrows_ShouldThrowException()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(failingRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ThrowsAsync(new InvalidOperationException("slow fail"));
var torrentMock = CreateTorrentMock();
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateSlowRulesAsync(torrentMock.Object));
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WithSpeedBasedRule_ShouldUseSlowSpeedStrikeType()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Rule",
resetOnProgress: false,
maxStrikes: 3,
minSpeed: "1 MB",
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
strikerMock.Verify(
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, It.IsAny<long?>()),
Times.Once);
strikerMock.Verify(
x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()),
Times.Never);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WithBothSpeedAndTimeConfigured_ShouldTreatAsSlowSpeed()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Both Rule",
resetOnProgress: false,
maxStrikes: 2,
minSpeed: "500 KB",
maxTimeHours: 2);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, It.IsAny<long?>()), Times.Once);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WithNeitherSpeedNorTimeConfigured_ShouldNotStrike()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
// Neither minSpeed nor maxTime set (maxTimeHours = 0, minSpeed = null)
var slowRule = CreateSlowRule(
name: "Fallback Rule",
resetOnProgress: false,
maxStrikes: 1,
minSpeed: null,
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()), Times.Never);
}
[Fact]
public async Task EvaluateSlowRulesAsync_SpeedBasedRule_WhenSpeedIsAcceptable_ShouldResetSlowSpeedStrikes()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Reset",
resetOnProgress: true,
maxStrikes: 3,
minSpeed: "1 MB",
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.SlowSpeed))
.Returns(Task.CompletedTask);
var torrentMock = CreateTorrentMock();
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("2 MB").Bytes); // Speed is above 1 MB threshold
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.SlowSpeed), Times.Once);
}
[Fact]
public async Task EvaluateSlowRulesAsync_SpeedBasedRule_WithResetDisabled_ShouldNotReset()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed No Reset",
resetOnProgress: false,
maxStrikes: 3,
minSpeed: "1 MB",
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("2 MB").Bytes); // Speed is above threshold
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()), Times.Never);
}
[Fact]
public async Task EvaluateSlowRulesAsync_TimeBasedRule_WithResetDisabled_ShouldNotReset()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Time No Reset",
resetOnProgress: false,
maxStrikes: 4,
minSpeed: null,
maxTimeHours: 2);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
torrentMock.SetupGet(t => t.Eta).Returns(1800); // ETA below threshold
await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()), Times.Never);
}
[Fact]
public async Task EvaluateSlowRulesAsync_SpeedBased_BelowThreshold_ShouldStrike()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Strike",
resetOnProgress: false,
maxStrikes: 3,
minSpeed: "5 MB",
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("1 MB").Bytes); // Speed below 5 MB threshold
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed, It.IsAny<long?>()), Times.Once);
}
[Fact]
public async Task EvaluateSlowRulesAsync_TimeBased_AboveThreshold_ShouldStrike()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Time Strike",
resetOnProgress: false,
maxStrikes: 5,
minSpeed: null,
maxTimeHours: 1);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
torrentMock.SetupGet(t => t.Eta).Returns(7200); // 2 hours, above 1 hour threshold
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
public async Task EvaluateStallRulesAsync_WithResetDisabled_ShouldNotReset()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
long downloadedBytes = ByteSize.Parse("50 MB").Bytes;
var torrentMock = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
// Progress made but reset disabled, so no reset
downloadedBytes = ByteSize.Parse("60 MB").Bytes;
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
}
[Fact]
public async Task EvaluateStallRulesAsync_WithProgressAndNoMinimumThreshold_ShouldReset()
{
// Arrange
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
context.DownloadItems.Add(downloadItem);
await context.SaveChangesAsync();
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
context.Strikes.Add(initialStrike);
await context.SaveChangesAsync();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
strikerMock
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
.Returns(Task.CompletedTask);
// Act - Any progress should trigger reset when no minimum is set
long downloadedBytes = ByteSize.Parse("1 KB").Bytes;
var torrentMock = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
// Assert
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
}
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
Func<long>? downloadedBytesFactory = null,
bool isPrivate = false,
string hash = "hash",
string name = "Example Torrent",
double completionPercentage = 50,
string size = "100 MB")
{
var torrentMock = new Mock<ITorrentItemWrapper>();
torrentMock.SetupGet(t => t.Hash).Returns(hash);
torrentMock.SetupGet(t => t.Name).Returns(name);
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytesFactory?.Invoke() ?? 0);
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
torrentMock.SetupGet(t => t.Eta).Returns(7200);
return torrentMock;
}
private static StallRule CreateStallRule(string name, bool resetOnProgress, int maxStrikes, string? minimumProgress = null, bool deletePrivateTorrentsFromClient = false)
{
return new StallRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = Guid.NewGuid(),
Name = name,
Enabled = true,
MaxStrikes = maxStrikes,
PrivacyType = TorrentPrivacyType.Public,
MinCompletionPercentage = 0,
MaxCompletionPercentage = 100,
ResetStrikesOnProgress = resetOnProgress,
MinimumProgress = minimumProgress,
DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient,
};
}
private static SlowRule CreateSlowRule(
string name,
bool resetOnProgress,
int maxStrikes,
string? minSpeed = null,
double maxTimeHours = 1,
bool deletePrivateTorrentsFromClient = false)
{
return new SlowRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = Guid.NewGuid(),
Name = name,
Enabled = true,
MaxStrikes = maxStrikes,
PrivacyType = TorrentPrivacyType.Public,
MinCompletionPercentage = 0,
MaxCompletionPercentage = 100,
ResetStrikesOnProgress = resetOnProgress,
MaxTimeHours = maxTimeHours,
MinSpeed = minSpeed ?? string.Empty,
IgnoreAboveSize = string.Empty,
DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient,
};
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenNoRuleMatches_ShouldReturnDeleteFromClientFalse()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((StallRule?)null);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenRuleMatchesButNoRemoval_ShouldReturnDeleteFromClientFalse()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientTrue_ShouldReturnTrue()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.Reason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateStallRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientFalse_ShouldReturnFalse()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenNoRuleMatches_ShouldReturnDeleteFromClientFalse()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((SlowRule?)null);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientTrue_ShouldReturnTrue()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowTime, result.Reason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesAndRemovesWithDeleteFromClientFalse_ShouldReturnFalse()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowTime, result.Reason);
Assert.False(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_SpeedBasedRuleWithDeleteFromClientTrue_ShouldReturnTrue()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
"Speed Delete True",
resetOnProgress: false,
maxStrikes: 3,
minSpeed: "5 MB",
maxTimeHours: 0,
deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(ByteSize.Parse("1 MB").Bytes);
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.Reason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task EvaluateSlowRulesAsync_WhenRuleMatchesButNoRemoval_ShouldReturnDeleteFromClientFalse()
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.Reason);
Assert.False(result.DeleteFromClient);
}
}

View File

@@ -0,0 +1,396 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class SeedingRuleEvaluatorTests
{
private readonly SeedingRuleEvaluator _sut = new();
private static ITorrentItemWrapper CreateTorrent(
string? category = "movies",
bool isPrivate = false,
IReadOnlyList<string>? trackerDomains = null,
IReadOnlyList<string>? tags = null)
{
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Category.Returns(category);
torrent.IsPrivate.Returns(isPrivate);
torrent.TrackerDomains.Returns(trackerDomains ?? Array.Empty<string>());
torrent.Tags.Returns(tags ?? Array.Empty<string>());
return torrent;
}
private static QBitSeedingRule CreateQBitRule(
int priority = 1,
List<string>? categories = null,
List<string>? trackerPatterns = null,
List<string>? tagsAny = null,
List<string>? tagsAll = null,
TorrentPrivacyType privacyType = TorrentPrivacyType.Both)
{
return new QBitSeedingRule
{
Id = Guid.NewGuid(),
Name = "Test Rule",
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = trackerPatterns ?? [],
TagsAny = tagsAny ?? [],
TagsAll = tagsAll ?? [],
PrivacyType = privacyType,
};
}
private static DelugeSeedingRule CreateDelugeRule(
int priority = 1,
List<string>? categories = null,
List<string>? trackerPatterns = null,
TorrentPrivacyType privacyType = TorrentPrivacyType.Both)
{
return new DelugeSeedingRule
{
Id = Guid.NewGuid(),
Name = "Test Rule",
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = trackerPatterns ?? [],
PrivacyType = privacyType,
};
}
// ──────────────────────────────────────────────────────────────────────
// Empty / null rules
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_EmptyRules_ReturnsNull()
{
var torrent = CreateTorrent();
_sut.GetMatchingRule(torrent, []).ShouldBeNull();
}
// ──────────────────────────────────────────────────────────────────────
// Category filtering
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_CategoryMatches_ReturnsRule()
{
var torrent = CreateTorrent(category: "movies");
var rule = CreateDelugeRule(categories: ["movies"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_CategoryMismatch_ReturnsNull()
{
var torrent = CreateTorrent(category: "tv");
var rule = CreateDelugeRule(categories: ["movies"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBeNull();
}
[Fact]
public void GetMatchingRule_CategoryMatchIsCaseInsensitive()
{
var torrent = CreateTorrent(category: "MOVIES");
var rule = CreateDelugeRule(categories: ["movies"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TorrentMatchesAnyCategory()
{
var torrent = CreateTorrent(category: "tv");
var rule = CreateDelugeRule(categories: ["movies", "tv", "music"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
// ──────────────────────────────────────────────────────────────────────
// Tracker pattern filtering
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_EmptyTrackerPatterns_MatchesAnyTracker()
{
var torrent = CreateTorrent(trackerDomains: ["tracker.example.com"]);
var rule = CreateDelugeRule(trackerPatterns: []);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TrackerPatternSuffixMatches()
{
var torrent = CreateTorrent(trackerDomains: ["tracker.example.com"]);
var rule = CreateDelugeRule(trackerPatterns: ["example.com"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TrackerPatternNoMatch_ReturnsNull()
{
var torrent = CreateTorrent(trackerDomains: ["tracker.other.org"]);
var rule = CreateDelugeRule(trackerPatterns: ["example.com"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBeNull();
}
[Fact]
public void GetMatchingRule_TrackerPatternMatchIsCaseInsensitive()
{
var torrent = CreateTorrent(trackerDomains: ["TRACKER.EXAMPLE.COM"]);
var rule = CreateDelugeRule(trackerPatterns: ["example.com"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TorrentMatchesAnyTrackerPattern()
{
var torrent = CreateTorrent(trackerDomains: ["tracker.private.org"]);
var rule = CreateDelugeRule(trackerPatterns: ["example.com", "private.org"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
// ──────────────────────────────────────────────────────────────────────
// Privacy type filtering
// ──────────────────────────────────────────────────────────────────────
[Theory]
[InlineData(TorrentPrivacyType.Public, false, true)]
[InlineData(TorrentPrivacyType.Public, true, false)]
[InlineData(TorrentPrivacyType.Private, false, false)]
[InlineData(TorrentPrivacyType.Private, true, true)]
[InlineData(TorrentPrivacyType.Both, false, true)]
[InlineData(TorrentPrivacyType.Both, true, true)]
public void GetMatchingRule_PrivacyType(TorrentPrivacyType rulePrivacy, bool torrentIsPrivate, bool shouldMatch)
{
var torrent = CreateTorrent(isPrivate: torrentIsPrivate);
var rule = CreateDelugeRule(privacyType: rulePrivacy);
var result = _sut.GetMatchingRule(torrent, [rule]);
if (shouldMatch)
result.ShouldBe(rule);
else
result.ShouldBeNull();
}
// ──────────────────────────────────────────────────────────────────────
// TagsAny filtering (ITagFilterable — QBit/Transmission only)
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_TagsAny_EmptyList_MatchesAnyTorrent()
{
var torrent = CreateTorrent(tags: ["some-tag"]);
var rule = CreateQBitRule(tagsAny: []);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TagsAny_TorrentHasMatchingTag_Matches()
{
var torrent = CreateTorrent(tags: ["hd", "private"]);
var rule = CreateQBitRule(tagsAny: ["private"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TagsAny_TorrentLacksAllTags_ReturnsNull()
{
var torrent = CreateTorrent(tags: ["hd"]);
var rule = CreateQBitRule(tagsAny: ["private", "special"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBeNull();
}
[Fact]
public void GetMatchingRule_TagsAny_MatchIsCaseInsensitive()
{
var torrent = CreateTorrent(tags: ["PRIVATE"]);
var rule = CreateQBitRule(tagsAny: ["private"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
// ──────────────────────────────────────────────────────────────────────
// TagsAll filtering (ITagFilterable — QBit/Transmission only)
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_TagsAll_EmptyList_MatchesAnyTorrent()
{
var torrent = CreateTorrent(tags: []);
var rule = CreateQBitRule(tagsAll: []);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TagsAll_TorrentHasAllTags_Matches()
{
var torrent = CreateTorrent(tags: ["hd", "private", "bonus"]);
var rule = CreateQBitRule(tagsAll: ["hd", "private"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TagsAll_TorrentMissingOneTag_ReturnsNull()
{
var torrent = CreateTorrent(tags: ["hd"]);
var rule = CreateQBitRule(tagsAll: ["hd", "private"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBeNull();
}
// ──────────────────────────────────────────────────────────────────────
// ITagFilterable not applied for non-tag clients (e.g. Deluge)
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_DelugeRule_TagsIgnored_Matches()
{
// Deluge doesn't implement ITagFilterable, so tag properties on the rule are not checked
var torrent = CreateTorrent(tags: []);
var rule = CreateDelugeRule(); // no tag support
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
// ──────────────────────────────────────────────────────────────────────
// Priority ordering
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_ReturnsLowestPriorityRule_WhenMultipleMatch()
{
var torrent = CreateTorrent(category: "movies");
var highPriority = CreateDelugeRule(priority: 1, categories: ["movies"]);
var lowPriority = CreateDelugeRule(priority: 5, categories: ["movies"]);
// Pass in reverse order to ensure sorting is applied
var result = _sut.GetMatchingRule(torrent, [lowPriority, highPriority]);
result.ShouldBe(highPriority);
}
[Fact]
public void GetMatchingRule_OnlyMatchingRuleReturned_OtherFiltered()
{
var torrent = CreateTorrent(
category: "movies",
trackerDomains: ["tracker.private.org"]);
var publicRule = CreateDelugeRule(
priority: 1,
categories: ["movies"],
trackerPatterns: ["public.com"]); // does not match tracker
var privateRule = CreateDelugeRule(
priority: 2,
categories: ["movies"],
trackerPatterns: ["private.org"]); // matches tracker
var result = _sut.GetMatchingRule(torrent, [publicRule, privateRule]);
result.ShouldBe(privateRule);
}
// ──────────────────────────────────────────────────────────────────────
// Combined criteria (AND logic)
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_AllCriteriaMustMatch()
{
var torrent = CreateTorrent(
category: "movies",
isPrivate: true,
trackerDomains: ["tracker.example.com"],
tags: ["hd"]);
var rule = CreateQBitRule(
categories: ["movies"],
trackerPatterns: ["example.com"],
tagsAny: ["hd"],
privacyType: TorrentPrivacyType.Private);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_OneCriterionFails_ReturnsNull()
{
var torrent = CreateTorrent(
category: "movies",
isPrivate: true,
trackerDomains: ["tracker.example.com"],
tags: ["hd"]);
// tracker pattern doesn't match
var rule = CreateQBitRule(
categories: ["movies"],
trackerPatterns: ["other.com"],
tagsAny: ["hd"],
privacyType: TorrentPrivacyType.Private);
_sut.GetMatchingRule(torrent, [rule]).ShouldBeNull();
}
// ──────────────────────────────────────────────────────────────────────
// Edge cases
// ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetMatchingRule_NullCategory_MatchesEmptyStringCategory()
{
var torrent = CreateTorrent(category: null);
var rule = CreateDelugeRule(categories: [""]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_NullCategory_DoesNotMatchNamedCategory()
{
var torrent = CreateTorrent(category: null);
var rule = CreateDelugeRule(categories: ["movies"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBeNull();
}
[Fact]
public void GetMatchingRule_TrackerPatternExactDomainMatch()
{
var torrent = CreateTorrent(trackerDomains: ["example.com"]);
var rule = CreateDelugeRule(trackerPatterns: ["example.com"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_TrackerPatternSuffixMatchesPartialDomain()
{
// "le.com" is a suffix of "example.com" — EndsWith will match this
var torrent = CreateTorrent(trackerDomains: ["example.com"]);
var rule = CreateDelugeRule(trackerPatterns: ["le.com"]);
// This matches because EndsWith is used (documenting current behavior)
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
}

View File

@@ -0,0 +1,50 @@
using Cleanuparr.Infrastructure.Services;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class UriServiceTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void GetDomain_NullOrWhitespace_ReturnsNull(string? input)
{
UriService.GetDomain(input).ShouldBeNull();
}
[Theory]
[InlineData("http://tracker.example.com/announce", "tracker.example.com")]
[InlineData("http://tracker.example.com:8080/announce", "tracker.example.com")]
[InlineData("https://tracker.example.com/announce", "tracker.example.com")]
[InlineData("https://tracker.example.com:443/announce", "tracker.example.com")]
public void GetDomain_HttpUrls_ReturnsDomain(string input, string expected)
{
UriService.GetDomain(input).ShouldBe(expected);
}
[Theory]
[InlineData("udp://tracker.opentrackr.org:1337/announce", "tracker.opentrackr.org")]
[InlineData("udp://open.stealth.si:80/announce", "open.stealth.si")]
public void GetDomain_UdpTrackerUrls_ReturnsDomain(string input, string expected)
{
UriService.GetDomain(input).ShouldBe(expected);
}
[Theory]
[InlineData("tracker.example.com", "tracker.example.com")]
[InlineData("tracker.example.com:8080", "tracker.example.com")]
[InlineData("tracker.example.com/announce", "tracker.example.com")]
public void GetDomain_NoScheme_PrependsHttpAndReturnsDomain(string input, string expected)
{
UriService.GetDomain(input).ShouldBe(expected);
}
[Fact]
public void GetDomain_PlainDomain_ReturnsDomain()
{
UriService.GetDomain("example.com").ShouldBe("example.com");
}
}

View File

@@ -9,11 +9,19 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
/// </summary>
public sealed class DelugeItemWrapper : ITorrentItemWrapper
{
private readonly Lazy<IReadOnlyList<string>> _trackerDomains;
public DownloadStatus Info { get; }
public DelugeItemWrapper(DownloadStatus downloadStatus)
{
Info = downloadStatus ?? throw new ArgumentNullException(nameof(downloadStatus));
_trackerDomains = new Lazy<IReadOnlyList<string>>(() => Info.Trackers
.Select(t => UriService.GetDomain(t.Url))
.Where(d => d is not null)
.Select(d => d!)
.ToList()
.AsReadOnly());
}
public string Hash => Info.Hash ?? string.Empty;
@@ -46,6 +54,10 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper
public string SavePath => Info.DownloadLocation ?? string.Empty;
public IReadOnlyList<string> TrackerDomains => _trackerDomains.Value;
public IReadOnlyList<string> Tags => Array.Empty<string>();
public bool IsDownloading() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsStalled() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true && Info is { DownloadSpeed: <= 0, Eta: <= 0 };

View File

@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
@@ -29,12 +30,12 @@ public partial class DelugeService : DownloadService, IDelugeService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator
) : base(
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
var delugeClient = new DelugeClient(downloadClientConfig, _httpClient);
@@ -52,13 +53,13 @@ public partial class DelugeService : DownloadService, IDelugeService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator,
IDelugeClientWrapper clientWrapper
) : base(
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
_client = clientWrapper;

View File

@@ -28,7 +28,7 @@ public partial class DelugeService
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>

View File

@@ -102,7 +102,7 @@ public partial class DelugeService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
@@ -113,6 +113,6 @@ public partial class DelugeService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -26,8 +26,8 @@ public abstract class DownloadService : IDownloadService
protected readonly IBlocklistProvider _blocklistProvider;
protected readonly HttpClient _httpClient;
protected readonly DownloadClientConfig _downloadClientConfig;
protected readonly IRuleEvaluator _ruleEvaluator;
protected readonly IRuleManager _ruleManager;
protected readonly IQueueRuleEvaluator _queueRuleEvaluator;
private readonly ISeedingRuleEvaluator _seedingRuleEvaluator;
protected DownloadService(
ILogger<DownloadService> logger,
@@ -39,8 +39,8 @@ public abstract class DownloadService : IDownloadService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator
)
{
_logger = logger;
@@ -52,8 +52,8 @@ public abstract class DownloadService : IDownloadService
_blocklistProvider = blocklistProvider;
_downloadClientConfig = downloadClientConfig;
_httpClient = httpClientProvider.CreateClient(downloadClientConfig);
_ruleEvaluator = ruleEvaluator;
_ruleManager = ruleManager;
_queueRuleEvaluator = queueRuleEvaluator;
_seedingRuleEvaluator = seedingRuleEvaluator;
}
public DownloadClientConfig ClientConfig => _downloadClientConfig;
@@ -95,45 +95,40 @@ public abstract class DownloadService : IDownloadService
continue;
}
ISeedingRule? category = seedingRules
.FirstOrDefault(x =>
(torrent.Category ?? string.Empty).Equals(x.Name, StringComparison.InvariantCultureIgnoreCase) &&
x.PrivacyType switch
{
TorrentPrivacyType.Public => !torrent.IsPrivate,
TorrentPrivacyType.Private => torrent.IsPrivate,
_ => true
});
ISeedingRule? seedingRule = _seedingRuleEvaluator.GetMatchingRule(torrent, seedingRules);
if (category is null)
if (seedingRule is null)
{
_logger.LogTrace("No seeding rules matched | {name}", torrent.Name);
continue;
}
_logger.LogTrace("Seeding rule matched | {seedingRule} | {name}", seedingRule.Name, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds);
SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, category);
SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, seedingRule);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(() => DeleteDownload(torrent, category.DeleteSourceFiles));
await _dryRunInterceptor.InterceptAsync(() => DeleteDownload(torrent, seedingRule.DeleteSourceFiles));
_logger.LogInformation(
"download cleaned | {reason} reached | delete files: {deleteFiles} | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
category.DeleteSourceFiles,
seedingRule.DeleteSourceFiles,
torrent.Name
);
await _eventPublisher.PublishDownloadCleaned(torrent.Ratio, seedingTime, category.Name, result.Reason);
await _eventPublisher.PublishDownloadCleaned(torrent.Ratio, seedingTime, torrent.Category ?? string.Empty, result.Reason);
}
}

View File

@@ -71,13 +71,13 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IQueueRuleEvaluator>();
var seedingRuleEvaluator = _serviceProvider.GetRequiredService<ISeedingRuleEvaluator>();
// Create the QBitService instance
QBitService service = new(
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, seedingRuleEvaluator
);
return service;
@@ -94,13 +94,13 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IQueueRuleEvaluator>();
var seedingRuleEvaluator = _serviceProvider.GetRequiredService<ISeedingRuleEvaluator>();
// Create the DelugeService instance
DelugeService service = new(
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, seedingRuleEvaluator
);
return service;
@@ -117,13 +117,13 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IQueueRuleEvaluator>();
var seedingRuleEvaluator = _serviceProvider.GetRequiredService<ISeedingRuleEvaluator>();
// Create the TransmissionService instance
TransmissionService service = new(
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, seedingRuleEvaluator
);
return service;
@@ -142,13 +142,13 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IQueueRuleEvaluator>();
var seedingRuleEvaluator = _serviceProvider.GetRequiredService<ISeedingRuleEvaluator>();
// Create the UTorrentService instance
UTorrentService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory, ruleEvaluator, ruleManager
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory, ruleEvaluator, seedingRuleEvaluator
);
return service;
@@ -165,13 +165,13 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IQueueRuleEvaluator>();
var seedingRuleEvaluator = _serviceProvider.GetRequiredService<ISeedingRuleEvaluator>();
// Create the RTorrentService instance
RTorrentService service = new(
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, seedingRuleEvaluator
);
return service;

View File

@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Infrastructure.Services;
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
@@ -11,6 +12,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
public sealed class QBitItemWrapper : ITorrentItemWrapper
{
private readonly IReadOnlyList<TorrentTracker> _trackers;
private readonly Lazy<IReadOnlyList<string>> _trackerDomains;
public TorrentInfo Info { get; }
@@ -19,6 +21,12 @@ public sealed class QBitItemWrapper : ITorrentItemWrapper
Info = torrentInfo ?? throw new ArgumentNullException(nameof(torrentInfo));
_trackers = trackers ?? throw new ArgumentNullException(nameof(trackers));
IsPrivate = isPrivate;
_trackerDomains = new Lazy<IReadOnlyList<string>>(() => _trackers
.Select(t => UriService.GetDomain(t.Url))
.Where(d => d is not null)
.Select(d => d!)
.ToList()
.AsReadOnly());
}
public string Hash => Info.Hash ?? string.Empty;
@@ -49,6 +57,8 @@ public sealed class QBitItemWrapper : ITorrentItemWrapper
public string SavePath => Info.SavePath ?? string.Empty;
public IReadOnlyList<string> TrackerDomains => _trackerDomains.Value;
public IReadOnlyList<string> Tags => Info.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
public bool IsDownloading() => Info.State is TorrentState.Downloading or TorrentState.ForcedDownload;

View File

@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
@@ -30,11 +31,11 @@ public partial class QBitService : DownloadService, IQBitService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator
) : base(
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
var qBittorrentClient = new QBittorrentClient(_httpClient, downloadClientConfig.Url);
@@ -52,12 +53,12 @@ public partial class QBitService : DownloadService, IQBitService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator,
IQBittorrentClientWrapper clientWrapper
) : base(
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
_client = clientWrapper;

View File

@@ -37,7 +37,7 @@ public partial class QBitService
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
/// <inheritdoc/>

View File

@@ -102,7 +102,7 @@ public partial class QBitService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
@@ -132,6 +132,6 @@ public partial class QBitService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -11,6 +11,7 @@ public sealed class RTorrentItemWrapper : ITorrentItemWrapper
{
public RTorrentTorrent Info { get; }
private readonly IReadOnlyList<string> _trackers;
private readonly Lazy<IReadOnlyList<string>> _trackerDomains;
private string? _category;
public RTorrentItemWrapper(RTorrentTorrent torrent, IReadOnlyList<string>? trackers = null)
@@ -18,6 +19,12 @@ public sealed class RTorrentItemWrapper : ITorrentItemWrapper
Info = torrent ?? throw new ArgumentNullException(nameof(torrent));
_trackers = trackers ?? torrent.Trackers ?? [];
_category = torrent.Label;
_trackerDomains = new Lazy<IReadOnlyList<string>>(() => _trackers
.Select(url => UriService.GetDomain(url))
.Where(d => d is not null)
.Select(d => d!)
.ToList()
.AsReadOnly());
}
public string Hash => Info.Hash;
@@ -53,6 +60,10 @@ public sealed class RTorrentItemWrapper : ITorrentItemWrapper
public string SavePath => Info.BasePath ?? string.Empty;
public IReadOnlyList<string> TrackerDomains => _trackerDomains.Value;
public IReadOnlyList<string> Tags => Array.Empty<string>();
/// <summary>
/// Downloading when state is 1 (started) and complete is 0 (not finished)
/// </summary>

View File

@@ -25,11 +25,11 @@ public partial class RTorrentService : DownloadService, IRTorrentService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator
) : base(
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
var rtorrentClient = new RTorrentClient(downloadClientConfig, _httpClient);
@@ -47,12 +47,12 @@ public partial class RTorrentService : DownloadService, IRTorrentService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator,
IRTorrentClientWrapper clientWrapper
) : base(
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
_client = clientWrapper;

View File

@@ -23,7 +23,7 @@ public partial class RTorrentService
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>

View File

@@ -93,7 +93,7 @@ public partial class RTorrentService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
@@ -104,6 +104,6 @@ public partial class RTorrentService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -10,11 +10,19 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
/// </summary>
public sealed class TransmissionItemWrapper : ITorrentItemWrapper
{
private readonly Lazy<IReadOnlyList<string>> _trackerDomains;
public TorrentInfo Info { get; }
public TransmissionItemWrapper(TorrentInfo torrentInfo)
{
Info = torrentInfo ?? throw new ArgumentNullException(nameof(torrentInfo));
_trackerDomains = new Lazy<IReadOnlyList<string>>(() => Info.Trackers?
.Select(t => UriService.GetDomain(t.Announce))
.Where(d => d is not null)
.Select(d => d!)
.ToList()
.AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>());
}
public string Hash => Info.HashString ?? string.Empty;
@@ -47,6 +55,11 @@ public sealed class TransmissionItemWrapper : ITorrentItemWrapper
public string SavePath => Info.DownloadDir ?? string.Empty;
public IReadOnlyList<string> TrackerDomains => _trackerDomains.Value;
public IReadOnlyList<string> Tags => Info.Labels?.ToList().AsReadOnly()
?? (IReadOnlyList<string>)Array.Empty<string>();
// Transmission status: 0=stopped, 1=check pending, 2=checking, 3=download pending, 4=downloading, 5=seed pending, 6=seeding
public bool IsDownloading() => Info.Status == 4;
public bool IsStalled() => Info is { Status: 4, RateDownload: <= 0, Eta: <= 0 };

View File

@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
@@ -36,6 +37,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS,
];
public TransmissionService(
@@ -48,12 +50,12 @@ public partial class TransmissionService : DownloadService, ITransmissionService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator
) : base(
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
UriBuilder uriBuilder = new(_downloadClientConfig.Url);
@@ -78,13 +80,13 @@ public partial class TransmissionService : DownloadService, ITransmissionService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator,
ITransmissionClientWrapper clientWrapper
) : base(
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
_client = clientWrapper;

View File

@@ -24,9 +24,7 @@ public partial class TransmissionService
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules)
{
return downloads
?.Where(x => seedingRules
.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))
)
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
}

View File

@@ -107,7 +107,7 @@ public partial class TransmissionService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
@@ -118,6 +118,6 @@ public partial class TransmissionService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
@@ -9,6 +10,8 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// </summary>
public sealed class UTorrentItemWrapper : ITorrentItemWrapper
{
private readonly Lazy<IReadOnlyList<string>> _trackerDomains;
public UTorrentItem Info { get; }
public UTorrentProperties Properties { get; }
@@ -17,6 +20,12 @@ public sealed class UTorrentItemWrapper : ITorrentItemWrapper
{
Info = torrentItem ?? throw new ArgumentNullException(nameof(torrentItem));
Properties = torrentProperties ?? throw new ArgumentNullException(nameof(torrentProperties));
_trackerDomains = new Lazy<IReadOnlyList<string>>(() => Properties.TrackerList
.Select(url => UriService.GetDomain(url))
.Where(d => d is not null)
.Select(d => d!)
.ToList()
.AsReadOnly());
}
public string Hash => Info.Hash;
@@ -47,6 +56,10 @@ public sealed class UTorrentItemWrapper : ITorrentItemWrapper
public string SavePath => Info.SavePath ?? string.Empty;
public IReadOnlyList<string> TrackerDomains => _trackerDomains.Value;
public IReadOnlyList<string> Tags => Array.Empty<string>();
public bool IsDownloading() =>
(Info.Status & UTorrentStatus.Started) != 0 &&
(Info.Status & UTorrentStatus.Checked) != 0 &&

View File

@@ -32,12 +32,12 @@ public partial class UTorrentService : DownloadService, IUTorrentService
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
ILoggerFactory loggerFactory,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator
) : base(
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
// Create the new layered client with dependency injection
@@ -71,13 +71,13 @@ public partial class UTorrentService : DownloadService, IUTorrentService
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IQueueRuleEvaluator queueRuleEvaluator,
ISeedingRuleEvaluator seedingRuleEvaluator,
IUTorrentClientWrapper clientWrapper
) : base(
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator
)
{
_client = clientWrapper;

View File

@@ -27,7 +27,7 @@ public partial class UTorrentService
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>

View File

@@ -102,7 +102,7 @@ public partial class UTorrentService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
@@ -113,6 +113,6 @@ public partial class UTorrentService
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -4,7 +4,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient;
namespace Cleanuparr.Infrastructure.Services.Interfaces;
public interface IRuleEvaluator
public interface IQueueRuleEvaluator
{
Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent);
Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateSlowRulesAsync(ITorrentItemWrapper torrent);

View File

@@ -3,7 +3,7 @@ using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
namespace Cleanuparr.Infrastructure.Services.Interfaces;
public interface IRuleManager
public interface IQueueRuleManager
{
StallRule? GetMatchingStallRule(ITorrentItemWrapper torrent);
SlowRule? GetMatchingSlowRule(ITorrentItemWrapper torrent);

View File

@@ -0,0 +1,12 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Infrastructure.Services.Interfaces;
public interface ISeedingRuleEvaluator
{
/// <summary>
/// Returns the highest-priority matching seeding rule for the given torrent
/// </summary>
ISeedingRule? GetMatchingRule(ITorrentItemWrapper torrent, IEnumerable<ISeedingRule> rules);
}

View File

@@ -1,32 +1,29 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.State;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Services;
public class RuleEvaluator : IRuleEvaluator
public class QueueRuleEvaluator : IQueueRuleEvaluator
{
private readonly IRuleManager _ruleManager;
private readonly IQueueRuleManager _queueRuleManager;
private readonly IStriker _striker;
private readonly EventsContext _context;
private readonly ILogger<RuleEvaluator> _logger;
private readonly ILogger<QueueRuleEvaluator> _logger;
public RuleEvaluator(
IRuleManager ruleManager,
public QueueRuleEvaluator(
IQueueRuleManager queueRuleManager,
IStriker striker,
EventsContext context,
ILogger<RuleEvaluator> logger)
ILogger<QueueRuleEvaluator> logger)
{
_ruleManager = ruleManager;
_queueRuleManager = queueRuleManager;
_striker = striker;
_context = context;
_logger = logger;
@@ -36,7 +33,7 @@ public class RuleEvaluator : IRuleEvaluator
{
_logger.LogTrace("Evaluating stall rules | {name}", torrent.Name);
var rule = _ruleManager.GetMatchingStallRule(torrent);
var rule = _queueRuleManager.GetMatchingStallRule(torrent);
if (rule is null)
{
@@ -74,7 +71,7 @@ public class RuleEvaluator : IRuleEvaluator
{
_logger.LogTrace("Evaluating slow rules | {name}", torrent.Name);
SlowRule? rule = _ruleManager.GetMatchingSlowRule(torrent);
SlowRule? rule = _queueRuleManager.GetMatchingSlowRule(torrent);
if (rule is null)
{

View File

@@ -6,11 +6,11 @@ using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Services;
public class RuleManager : IRuleManager
public class QueueRuleManager : IQueueRuleManager
{
private readonly ILogger<RuleManager> _logger;
private readonly ILogger<QueueRuleManager> _logger;
public RuleManager(ILogger<RuleManager> logger)
public QueueRuleManager(ILogger<QueueRuleManager> logger)
{
_logger = logger;
}

View File

@@ -0,0 +1,62 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Infrastructure.Services;
public class SeedingRuleEvaluator : ISeedingRuleEvaluator
{
public ISeedingRule? GetMatchingRule(ITorrentItemWrapper torrent, IEnumerable<ISeedingRule> rules)
{
return rules
.OrderBy(r => r.Priority)
.FirstOrDefault(rule => Matches(torrent, rule));
}
private static bool Matches(ITorrentItemWrapper torrent, ISeedingRule rule)
{
// Category check
if (!rule.Categories.Any(c => c.Equals(torrent.Category ?? string.Empty, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
// Tracker check
if (rule.TrackerPatterns.Count > 0)
{
bool hasMatchingTracker = torrent.TrackerDomains.Any(domain =>
rule.TrackerPatterns.Any(pattern =>
domain.EndsWith(pattern, StringComparison.OrdinalIgnoreCase)));
if (!hasMatchingTracker)
{
return false;
}
}
// Tag/label check
if (rule is ITagFilterable tagFilterable)
{
if (tagFilterable.TagsAny.Count > 0 &&
!tagFilterable.TagsAny.Any(t => torrent.Tags.Contains(t, StringComparer.OrdinalIgnoreCase)))
{
return false;
}
if (tagFilterable.TagsAll.Count > 0 &&
!tagFilterable.TagsAll.All(t => torrent.Tags.Contains(t, StringComparer.OrdinalIgnoreCase)))
{
return false;
}
}
// Privacy check
return rule.PrivacyType switch
{
TorrentPrivacyType.Public => !torrent.IsPrivate,
TorrentPrivacyType.Private => torrent.IsPrivate,
_ => true
};
}
}

View File

@@ -2,8 +2,11 @@
namespace Cleanuparr.Infrastructure.Services;
public static class UriService
public static partial class UriService
{
[GeneratedRegex(@"^(?:\w+:\/\/)?([^\/\?:]+)", RegexOptions.IgnoreCase)]
private static partial Regex DomainFallbackRegex();
public static string? GetDomain(string? input)
{
if (string.IsNullOrWhiteSpace(input))
@@ -12,7 +15,7 @@ public static class UriService
}
// add "http://" if scheme is missing to help Uri.TryCreate
if (!input.StartsWith("http", StringComparison.OrdinalIgnoreCase))
if (!input.Contains("://"))
{
input = "http://" + input;
}
@@ -23,9 +26,8 @@ public static class UriService
}
// url might be malformed
var regex = new Regex(@"^(?:https?:\/\/)?([^\/\?:]+)", RegexOptions.IgnoreCase);
var match = regex.Match(input);
var match = DomainFallbackRegex().Match(input);
if (match.Success)
{
return match.Groups[1].Value;

View File

@@ -0,0 +1,61 @@
using Cleanuparr.Persistence.Converters;
using Shouldly;
using Xunit;
namespace Cleanuparr.Persistence.Tests.Converters;
public class JsonStringListConverterTests
{
private readonly JsonStringListConverter _converter = new();
[Fact]
public void ConvertToProvider_WithValues_ReturnsJsonArray()
{
var list = new List<string> { "movies", "tv", "music" };
var result = _converter.ConvertToProviderExpression.Compile()(list);
result.ShouldBe("[\"movies\",\"tv\",\"music\"]");
}
[Fact]
public void ConvertToProvider_EmptyList_ReturnsEmptyJsonArray()
{
var list = new List<string>();
var result = _converter.ConvertToProviderExpression.Compile()(list);
result.ShouldBe("[]");
}
[Fact]
public void ConvertFromProvider_ValidJson_ReturnsList()
{
var json = "[\"movies\",\"tv\"]";
var result = _converter.ConvertFromProviderExpression.Compile()(json);
result.ShouldBe(new List<string> { "movies", "tv" });
}
[Fact]
public void ConvertFromProvider_EmptyArray_ReturnsEmptyList()
{
var json = "[]";
var result = _converter.ConvertFromProviderExpression.Compile()(json);
result.ShouldBeEmpty();
}
[Fact]
public void RoundTrip_PreservesValues()
{
var original = new List<string> { "tracker.example.com", "private.org" };
var json = _converter.ConvertToProviderExpression.Compile()(original);
var restored = _converter.ConvertFromProviderExpression.Compile()(json);
restored.ShouldBe(original);
}
}

View File

@@ -35,6 +35,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1,
@@ -50,6 +51,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = -1,
MinSeedTime = 0,
MaxSeedTime = 24,
@@ -65,6 +67,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = 2.0,
MinSeedTime = 1,
MaxSeedTime = 48,
@@ -80,6 +83,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = 0,
MinSeedTime = 0,
MaxSeedTime = -1,
@@ -95,6 +99,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = -1,
MinSeedTime = 0,
MaxSeedTime = 0,
@@ -106,6 +111,27 @@ public sealed class QBitSeedingRuleTests
#endregion
#region Validate - Categories Validation
[Fact]
public void Validate_WithEmptyCategories_ThrowsValidationException()
{
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = [],
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1,
DeleteSourceFiles = true
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("At least one category must be specified");
}
#endregion
#region Validate - Name Validation
[Fact]
@@ -166,6 +192,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = -1,
MinSeedTime = 0,
MaxSeedTime = -1,
@@ -185,6 +212,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = maxRatio,
MinSeedTime = 0,
MaxSeedTime = maxSeedTime,
@@ -205,6 +233,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = 2.0,
MinSeedTime = -1,
MaxSeedTime = -1,
@@ -224,6 +253,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = 2.0,
MinSeedTime = minSeedTime,
MaxSeedTime = -1,
@@ -240,6 +270,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1,
@@ -255,6 +286,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = 2.0,
MinSeedTime = 24,
MaxSeedTime = -1,
@@ -272,6 +304,7 @@ public sealed class QBitSeedingRuleTests
var config = new QBitSeedingRule
{
Name = "test-category",
Categories = ["test-category"],
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1,

View File

@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Text.Json;
namespace Cleanuparr.Persistence.Converters;
public class JsonStringListConverter : ValueConverter<List<string>, string>
{
public JsonStringListConverter() : base(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>())
{
}
}

View File

@@ -294,13 +294,20 @@ public class DataContext : DbContext
entity.Property(s => s.RecordedAt).HasConversion(new UtcDateTimeConverter());
});
// Configure per-client seeding rule relationships
// Configure per-client seeding rule relationships and JSON list converters
var jsonListConverter = new JsonStringListConverter();
modelBuilder.Entity<QBitSeedingRule>(entity =>
{
entity.HasOne(s => s.DownloadClientConfig)
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.Property(s => s.Categories).HasConversion(jsonListConverter);
entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter);
entity.Property(s => s.TagsAny).HasConversion(jsonListConverter);
entity.Property(s => s.TagsAll).HasConversion(jsonListConverter);
});
modelBuilder.Entity<DelugeSeedingRule>(entity =>
@@ -309,6 +316,9 @@ public class DataContext : DbContext
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.Property(s => s.Categories).HasConversion(jsonListConverter);
entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter);
});
modelBuilder.Entity<TransmissionSeedingRule>(entity =>
@@ -317,6 +327,11 @@ public class DataContext : DbContext
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.Property(s => s.Categories).HasConversion(jsonListConverter);
entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter);
entity.Property(s => s.TagsAny).HasConversion(jsonListConverter);
entity.Property(s => s.TagsAll).HasConversion(jsonListConverter);
});
modelBuilder.Entity<UTorrentSeedingRule>(entity =>
@@ -325,6 +340,9 @@ public class DataContext : DbContext
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.Property(s => s.Categories).HasConversion(jsonListConverter);
entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter);
});
modelBuilder.Entity<RTorrentSeedingRule>(entity =>
@@ -333,6 +351,9 @@ public class DataContext : DbContext
.WithMany()
.HasForeignKey(s => s.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.Property(s => s.Categories).HasConversion(jsonListConverter);
entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter);
});
// Configure per-client unlinked config relationship

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddSeedingRuleEnhancements : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "categories",
table: "u_torrent_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<int>(
name: "priority",
table: "u_torrent_seeding_rules",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "tracker_patterns",
table: "u_torrent_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "categories",
table: "transmission_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<int>(
name: "priority",
table: "transmission_seeding_rules",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "tags_all",
table: "transmission_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "tags_any",
table: "transmission_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "tracker_patterns",
table: "transmission_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "categories",
table: "r_torrent_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<int>(
name: "priority",
table: "r_torrent_seeding_rules",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "tracker_patterns",
table: "r_torrent_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "categories",
table: "q_bit_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<int>(
name: "priority",
table: "q_bit_seeding_rules",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "tags_all",
table: "q_bit_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "tags_any",
table: "q_bit_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "tracker_patterns",
table: "q_bit_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "categories",
table: "deluge_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<int>(
name: "priority",
table: "deluge_seeding_rules",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "tracker_patterns",
table: "deluge_seeding_rules",
type: "TEXT",
nullable: false,
defaultValue: "[]");
// Data migration: populate categories from name and assign sequential priorities per client
foreach (var table in new[] { "q_bit_seeding_rules", "deluge_seeding_rules", "transmission_seeding_rules", "u_torrent_seeding_rules", "r_torrent_seeding_rules" })
{
// Populate categories from existing name (preserves existing rule matching behaviour)
migrationBuilder.Sql($"UPDATE {table} SET categories = json_array(name)");
// Assign sequential priorities per download client, ordered by rowid
migrationBuilder.Sql($@"
UPDATE {table}
SET priority = (
SELECT COUNT(*)
FROM {table} r2
WHERE r2.download_client_config_id = {table}.download_client_config_id
AND r2.rowid <= {table}.rowid
)");
}
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "categories",
table: "u_torrent_seeding_rules");
migrationBuilder.DropColumn(
name: "priority",
table: "u_torrent_seeding_rules");
migrationBuilder.DropColumn(
name: "tracker_patterns",
table: "u_torrent_seeding_rules");
migrationBuilder.DropColumn(
name: "categories",
table: "transmission_seeding_rules");
migrationBuilder.DropColumn(
name: "priority",
table: "transmission_seeding_rules");
migrationBuilder.DropColumn(
name: "tags_all",
table: "transmission_seeding_rules");
migrationBuilder.DropColumn(
name: "tags_any",
table: "transmission_seeding_rules");
migrationBuilder.DropColumn(
name: "tracker_patterns",
table: "transmission_seeding_rules");
migrationBuilder.DropColumn(
name: "categories",
table: "r_torrent_seeding_rules");
migrationBuilder.DropColumn(
name: "priority",
table: "r_torrent_seeding_rules");
migrationBuilder.DropColumn(
name: "tracker_patterns",
table: "r_torrent_seeding_rules");
migrationBuilder.DropColumn(
name: "categories",
table: "q_bit_seeding_rules");
migrationBuilder.DropColumn(
name: "priority",
table: "q_bit_seeding_rules");
migrationBuilder.DropColumn(
name: "tags_all",
table: "q_bit_seeding_rules");
migrationBuilder.DropColumn(
name: "tags_any",
table: "q_bit_seeding_rules");
migrationBuilder.DropColumn(
name: "tracker_patterns",
table: "q_bit_seeding_rules");
migrationBuilder.DropColumn(
name: "categories",
table: "deluge_seeding_rules");
migrationBuilder.DropColumn(
name: "priority",
table: "deluge_seeding_rules");
migrationBuilder.DropColumn(
name: "tracker_patterns",
table: "deluge_seeding_rules");
}
}
}

View File

@@ -120,6 +120,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Categories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("categories");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
@@ -145,11 +150,20 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.Property<string>("TrackerPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tracker_patterns");
b.HasKey("Id")
.HasName("pk_deluge_seeding_rules");
@@ -197,6 +211,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Categories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("categories");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
@@ -222,11 +241,30 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.Property<string>("TagsAll")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags_all");
b.Property<string>("TagsAny")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags_any");
b.Property<string>("TrackerPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tracker_patterns");
b.HasKey("Id")
.HasName("pk_q_bit_seeding_rules");
@@ -243,6 +281,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Categories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("categories");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
@@ -268,11 +311,20 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.Property<string>("TrackerPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tracker_patterns");
b.HasKey("Id")
.HasName("pk_r_torrent_seeding_rules");
@@ -289,6 +341,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Categories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("categories");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
@@ -314,11 +371,30 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.Property<string>("TagsAll")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags_all");
b.Property<string>("TagsAny")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags_any");
b.Property<string>("TrackerPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tracker_patterns");
b.HasKey("Id")
.HasName("pk_transmission_seeding_rules");
@@ -335,6 +411,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Categories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("categories");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
@@ -360,11 +441,20 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("PrivacyType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("privacy_type");
b.Property<string>("TrackerPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tracker_patterns");
b.HasKey("Id")
.HasName("pk_u_torrent_seeding_rules");

View File

@@ -17,6 +17,12 @@ public sealed record DelugeSeedingRule : ISeedingRule
public string Name { get; set; } = string.Empty;
public List<string> Categories { get; set; } = [];
public List<string> TrackerPatterns { get; set; } = [];
public int Priority { get; set; }
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
@@ -49,6 +55,11 @@ public sealed record DelugeSeedingRule : ISeedingRule
throw new ValidationException("Rule name can not be empty");
}
if (Categories.Count == 0)
{
throw new ValidationException("At least one category must be specified");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");

View File

@@ -10,8 +10,27 @@ public interface ISeedingRule : IConfig
DownloadClientConfig DownloadClientConfig { get; set; }
/// <summary>
/// Human-readable display label for this rule.
/// </summary>
string Name { get; set; }
/// <summary>
/// The torrent categories/labels this rule applies to. At least one must be specified.
/// </summary>
List<string> Categories { get; set; }
/// <summary>
/// Tracker domain patterns to filter by (suffix match, case-insensitive).
/// Empty list means match any tracker.
/// </summary>
List<string> TrackerPatterns { get; set; }
/// <summary>
/// Evaluation order. Lower value = evaluated first. Auto-assigned on create.
/// </summary>
int Priority { get; set; }
TorrentPrivacyType PrivacyType { get; set; }
double MaxRatio { get; set; }

View File

@@ -0,0 +1,18 @@
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
/// <summary>
/// Marks a seeding rule as supporting tag/label-based filtering.
/// Implemented by clients that expose per-torrent tags: qBittorrent (tags) and Transmission (labels).
/// </summary>
public interface ITagFilterable
{
/// <summary>
/// The torrent must have at least one of these tags/labels. Empty = no tag filter.
/// </summary>
List<string> TagsAny { get; set; }
/// <summary>
/// The torrent must have ALL of these tags/labels. Empty = no tag filter.
/// </summary>
List<string> TagsAll { get; set; }
}

View File

@@ -5,7 +5,7 @@ using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record QBitSeedingRule : ISeedingRule
public sealed record QBitSeedingRule : ISeedingRule, ITagFilterable
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
@@ -17,6 +17,16 @@ public sealed record QBitSeedingRule : ISeedingRule
public string Name { get; set; } = string.Empty;
public List<string> Categories { get; set; } = [];
public List<string> TrackerPatterns { get; set; } = [];
public List<string> TagsAny { get; set; } = [];
public List<string> TagsAll { get; set; } = [];
public int Priority { get; set; }
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
@@ -49,6 +59,11 @@ public sealed record QBitSeedingRule : ISeedingRule
throw new ValidationException("Rule name can not be empty");
}
if (Categories.Count == 0)
{
throw new ValidationException("At least one category must be specified");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");

View File

@@ -17,6 +17,12 @@ public sealed record RTorrentSeedingRule : ISeedingRule
public string Name { get; set; } = string.Empty;
public List<string> Categories { get; set; } = [];
public List<string> TrackerPatterns { get; set; } = [];
public int Priority { get; set; }
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
@@ -49,6 +55,11 @@ public sealed record RTorrentSeedingRule : ISeedingRule
throw new ValidationException("Rule name can not be empty");
}
if (Categories.Count == 0)
{
throw new ValidationException("At least one category must be specified");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");

View File

@@ -5,7 +5,7 @@ using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record TransmissionSeedingRule : ISeedingRule
public sealed record TransmissionSeedingRule : ISeedingRule, ITagFilterable
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
@@ -17,6 +17,22 @@ public sealed record TransmissionSeedingRule : ISeedingRule
public string Name { get; set; } = string.Empty;
public List<string> Categories { get; set; } = [];
public List<string> TrackerPatterns { get; set; } = [];
/// <summary>
/// The torrent must have at least one of these Transmission labels. Empty = no label filter.
/// </summary>
public List<string> TagsAny { get; set; } = [];
/// <summary>
/// The torrent must have ALL of these Transmission labels. Empty = no label filter.
/// </summary>
public List<string> TagsAll { get; set; } = [];
public int Priority { get; set; }
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
@@ -49,6 +65,11 @@ public sealed record TransmissionSeedingRule : ISeedingRule
throw new ValidationException("Rule name can not be empty");
}
if (Categories.Count == 0)
{
throw new ValidationException("At least one category must be specified");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");

View File

@@ -17,6 +17,12 @@ public sealed record UTorrentSeedingRule : ISeedingRule
public string Name { get; set; } = string.Empty;
public List<string> Categories { get; set; } = [];
public List<string> TrackerPatterns { get; set; } = [];
public int Priority { get; set; }
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
@@ -49,6 +55,11 @@ public sealed record UTorrentSeedingRule : ISeedingRule
throw new ValidationException("Rule name can not be empty");
}
if (Categories.Count == 0)
{
throw new ValidationException("At least one category must be specified");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");