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");

View File

@@ -32,6 +32,10 @@ export class DownloadCleanerApi {
return this.http.delete<void>(`/api/seeding-rules/${id}`);
}
reorderSeedingRules(clientId: string, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`/api/seeding-rules/${clientId}/reorder`, { orderedIds });
}
// Unlinked config
getUnlinkedConfig(clientId: string): Observable<UnlinkedConfigModel | null> {
return this.http.get<UnlinkedConfigModel | null>(`/api/unlinked-config/${clientId}`);

View File

@@ -69,7 +69,11 @@ export class DocumentationService {
'scheduleUnit': 'scheduling-mode',
'scheduleEvery': 'scheduling-mode',
'cronExpression': 'cron-expression',
'name': 'category-name',
'name': 'rule-name',
'categories': 'categories',
'trackerPatterns': 'tracker-patterns',
'tagsAny': 'tags-any',
'tagsAll': 'tags-all',
'privacyType': 'privacy-type',
'maxRatio': 'max-ratio',
'minSeedTime': 'min-seed-time',

View File

@@ -93,36 +93,63 @@
description="Add a seeding rule to start cleaning downloads for this client"
/>
} @else {
@for (rule of client.seedingRules; track rule.id ?? $index) {
<div class="rule-card">
<div class="rule-card__header">
<h4 class="rule-card__name">{{ rule.name }}</h4>
<div class="rule-card__actions">
<button class="rule-card__action" (click)="openRuleModal(rule)" aria-label="Edit rule">
<ng-icon name="tablerPencil" size="16" />
</button>
<button class="rule-card__action rule-card__action--danger" (click)="deleteRule(rule)" aria-label="Delete rule">
<ng-icon name="tablerTrash" size="16" />
</button>
</div>
</div>
<div class="rule-card__badges">
<app-badge severity="info">{{ rule.privacyType }}</app-badge>
@if (rule.maxRatio >= 0) {
<app-badge>Ratio: {{ rule.maxRatio }}</app-badge>
}
@if (rule.maxSeedTime >= 0) {
<app-badge>Max Seed: {{ rule.maxSeedTime }}h</app-badge>
}
@if (rule.minSeedTime > 0) {
<app-badge>Min Seed: {{ rule.minSeedTime }}h</app-badge>
}
</div>
<div class="rule-card__details">
<span>Delete Files: {{ rule.deleteSourceFiles ? 'Yes' : 'No' }}</span>
</div>
@if (client.seedingRules.length > 1) {
<div class="rules-flow-hint">
<ng-icon name="tablerArrowDown" size="14" />
Rules are evaluated top to bottom · drag to reorder
</div>
}
<div
cdkDropList
[cdkDropListData]="client.seedingRules"
(cdkDropListDropped)="onRulesReorder($event)">
@for (rule of client.seedingRules; track rule.id ?? $index) {
<div class="rule-card" cdkDrag>
<div class="rule-card__header">
<button class="rule-card__drag-handle" cdkDragHandle title="Drag to reorder" type="button" aria-label="Drag to reorder">
<ng-icon name="tablerGripVertical" size="16" />
</button>
<app-tooltip text="Evaluation priority. Rules are matched top to bottom, first match wins.">
<span class="priority-badge">#{{ $index + 1 }}</span>
</app-tooltip>
<h4 class="rule-card__name">{{ rule.name }}</h4>
<div class="rule-card__actions">
<button class="rule-card__action" (click)="openRuleModal(rule)" aria-label="Edit rule">
<ng-icon name="tablerPencil" size="16" />
</button>
<button class="rule-card__action rule-card__action--danger" (click)="deleteRule(rule)" aria-label="Delete rule">
<ng-icon name="tablerTrash" size="16" />
</button>
</div>
</div>
@if (rule.categories.length) {
<div class="rule-card__categories">
@for (cat of rule.categories; track cat) {
<app-badge severity="default">{{ cat }}</app-badge>
}
</div>
}
<div class="rule-card__badges">
<app-badge severity="info">{{ rule.privacyType }}</app-badge>
@if (rule.maxRatio >= 0) {
<app-badge>Ratio: {{ rule.maxRatio }}</app-badge>
}
@if (rule.maxSeedTime >= 0) {
<app-badge>Max Seed: {{ rule.maxSeedTime }}h</app-badge>
}
@if (rule.minSeedTime > 0) {
<app-badge>Min Seed: {{ rule.minSeedTime }}h</app-badge>
}
@if (rule.trackerPatterns.length) {
<app-badge severity="warning">{{ rule.trackerPatterns.length }} tracker pattern{{ rule.trackerPatterns.length !== 1 ? 's' : '' }}</app-badge>
}
</div>
<div class="rule-card__details">
<span>Delete Files: {{ rule.deleteSourceFiles ? 'Yes' : 'No' }}</span>
</div>
</div>
}
</div>
}
<div class="rule-actions">
<app-button variant="secondary" size="sm" (clicked)="openRuleModal()">
@@ -200,13 +227,32 @@
<!-- Seeding Rule Modal -->
<app-modal [title]="editingRule() ? 'Edit Seeding Rule' : 'Add Seeding Rule'" [(visible)]="ruleModalVisible" size="lg">
<div class="form-grid">
<app-input label="Category Name" placeholder="tv-sonarr" [(value)]="ruleName"
<app-input label="Rule Name" placeholder="My TV rule" [(value)]="ruleName"
[error]="ruleNameError()"
hint="The category name from your download client (e.g. tv-sonarr, radarr)"
hint="A descriptive label for this rule"
helpKey="download-cleaner:name" />
<app-select label="Privacy Type" [options]="privacyTypeOptions" [(value)]="rulePrivacyType"
hint="Which torrent types this rule applies to"
helpKey="download-cleaner:privacyType" />
<app-chip-input class="full-width" label="Categories" placeholder="Add category..."
[(items)]="ruleCategories"
[error]="ruleCategoriesError()"
hint="One or more download client categories this rule applies to (e.g. tv-sonarr, radarr)"
helpKey="download-cleaner:categories" #ruleChipInput />
<app-chip-input class="full-width" label="Tracker Patterns" placeholder="Add tracker domain..."
[(items)]="ruleTrackerPatterns"
hint="Tracker domain suffixes to match (e.g. tracker.org). Empty means any tracker."
helpKey="download-cleaner:trackerPatterns" #ruleChipInput />
@if (isTagFilterableClient()) {
<app-chip-input class="full-width" [label]="isSelectedClientTransmission() ? 'Labels (Any)' : 'Tags (Any)'" [placeholder]="isSelectedClientTransmission() ? 'Add label...' : 'Add tag...'"
[(items)]="ruleTagsAny"
[hint]="isSelectedClientTransmission() ? 'Torrent must have at least one of these labels. Empty means any labels.' : 'Torrent must have at least one of these tags. Empty means any tags.'"
helpKey="download-cleaner:tagsAny" #ruleChipInput />
<app-chip-input class="full-width" [label]="isSelectedClientTransmission() ? 'Labels (All)' : 'Tags (All)'" [placeholder]="isSelectedClientTransmission() ? 'Add label...' : 'Add tag...'"
[(items)]="ruleTagsAll"
[hint]="isSelectedClientTransmission() ? 'Torrent must have all of these labels. Empty means any labels.' : 'Torrent must have all of these tags. Empty means any tags.'"
helpKey="download-cleaner:tagsAll" #ruleChipInput />
}
<app-number-input label="Max Ratio" [(value)]="ruleMaxRatio" [step]="0.1" [min]="-1"
hint="Maximum ratio to seed before removing (-1 means disabled)"
helpKey="download-cleaner:maxRatio" />
@@ -225,7 +271,7 @@
</div>
<div modal-footer>
<app-button variant="secondary" (clicked)="ruleModalVisible.set(false)">Cancel</app-button>
<app-button variant="primary" [disabled]="!!ruleNameError() || !!ruleDisabledError()" (clicked)="saveRule()">
<app-button variant="primary" [disabled]="!!ruleNameError() || !!ruleCategoriesError() || !!ruleDisabledError() || ruleHasUncommittedInputs()" (clicked)="saveRule()">
{{ editingRule() ? 'Update' : 'Create' }}
</app-button>
</div>

View File

@@ -42,11 +42,37 @@
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
&__drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
padding: 0;
color: var(--text-secondary);
cursor: grab;
opacity: 0.5;
flex-shrink: 0;
transition: opacity var(--duration-fast) var(--ease-default), color var(--duration-fast) var(--ease-default);
&:hover {
opacity: 1;
color: var(--text-primary);
}
&:active {
cursor: grabbing;
}
}
&__name {
flex: 1;
font-size: var(--font-size-md);
font-weight: 600;
color: var(--text-primary);
@@ -83,6 +109,13 @@
}
}
&__categories {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
&__badges {
display: flex;
flex-wrap: wrap;
@@ -99,10 +132,49 @@
}
}
// CDK drag-and-drop styles for rule cards
.cdk-drag-preview {
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
opacity: 0.95;
cursor: grabbing;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms var(--ease-default);
}
.rule-actions {
margin-top: var(--space-3);
}
.rules-flow-hint {
display: flex;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-2);
color: var(--color-error);
font-size: var(--font-size-xs);
}
.priority-badge {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-tertiary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 2px 6px;
flex-shrink: 0;
min-width: 28px;
text-align: center;
line-height: 1.4;
}
.rules-loading {
display: flex;
align-items: center;

View File

@@ -1,10 +1,12 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren, effect, untracked } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle, moveItemInArray } from '@angular/cdk/drag-drop';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
TooltipComponent,
type SelectOption,
} from '@ui';
import { DownloadCleanerApi } from '@core/api/download-cleaner.api';
@@ -38,9 +40,11 @@ const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
standalone: true,
imports: [
NgIconComponent,
CdkDropList, CdkDrag, CdkDragHandle,
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
TooltipComponent,
],
templateUrl: './download-cleaner.component.html',
styleUrl: './download-cleaner.component.scss',
@@ -51,6 +55,11 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
private readonly toast = inject(ToastService);
private readonly confirm = inject(ConfirmService);
private readonly chipInputs = viewChildren(ChipInputComponent);
private readonly ruleChipInputs = viewChildren<ChipInputComponent>('ruleChipInput');
readonly ruleHasUncommittedInputs = computed(() =>
this.ruleChipInputs().some(c => c.hasUncommittedInput())
);
private readonly savedSnapshot = signal('');
@@ -95,6 +104,15 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
this.selectedClient()?.downloadClientTypeName === DownloadClientTypeName.qBittorrent
);
readonly isSelectedClientTransmission = computed(() =>
this.selectedClient()?.downloadClientTypeName === DownloadClientTypeName.Transmission
);
readonly isTagFilterableClient = computed(() => {
const typeName = this.selectedClient()?.downloadClientTypeName;
return typeName === DownloadClientTypeName.qBittorrent || typeName === DownloadClientTypeName.Transmission;
});
readonly seedingRulesExpanded = signal(false);
readonly unlinkedExpanded = signal(false);
@@ -102,6 +120,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
readonly ruleModalVisible = signal(false);
readonly editingRule = signal<SeedingRule | null>(null);
readonly ruleName = signal('');
readonly ruleCategories = signal<string[]>([]);
readonly ruleTrackerPatterns = signal<string[]>([]);
readonly ruleTagsAny = signal<string[]>([]);
readonly ruleTagsAll = signal<string[]>([]);
readonly rulePrivacyType = signal<unknown>(TorrentPrivacyType.Public);
readonly ruleMaxRatio = signal<number | null>(-1);
readonly ruleMinSeedTime = signal<number | null>(0);
@@ -143,6 +165,11 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return undefined;
});
readonly ruleCategoriesError = computed(() => {
if (this.ruleCategories().length === 0) return 'At least one category is required';
return undefined;
});
readonly ruleDisabledError = computed(() => {
if ((this.ruleMaxRatio() ?? -1) < 0 && (this.ruleMaxSeedTime() ?? -1) < 0) {
return 'Both max ratio and max seed time cannot be disabled at the same time';
@@ -232,6 +259,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
this.editingRule.set(rule ?? null);
if (rule) {
this.ruleName.set(rule.name);
this.ruleCategories.set([...(rule.categories ?? [])]);
this.ruleTrackerPatterns.set([...(rule.trackerPatterns ?? [])]);
this.ruleTagsAny.set([...(rule.tagsAny ?? [])]);
this.ruleTagsAll.set([...(rule.tagsAll ?? [])]);
this.rulePrivacyType.set(rule.privacyType);
this.ruleMaxRatio.set(rule.maxRatio);
this.ruleMinSeedTime.set(rule.minSeedTime);
@@ -239,6 +270,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
this.ruleDeleteSourceFiles.set(rule.deleteSourceFiles);
} else {
this.ruleName.set('');
this.ruleCategories.set([]);
this.ruleTrackerPatterns.set([]);
this.ruleTagsAny.set([]);
this.ruleTagsAll.set([]);
this.rulePrivacyType.set(TorrentPrivacyType.Public);
this.ruleMaxRatio.set(-1);
this.ruleMinSeedTime.set(0);
@@ -249,12 +284,18 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
saveRule(): void {
if (this.ruleNameError() || this.ruleDisabledError()) return;
if (this.ruleNameError() || this.ruleCategoriesError() || this.ruleDisabledError() || this.ruleHasUncommittedInputs()) return;
const clientId = this.selectedClientId();
if (!clientId) return;
const sanitize = (list: string[]) => list.map(s => s.trim()).filter(s => s.length > 0);
const dto: Partial<SeedingRule> = {
name: this.ruleName().trim(),
categories: sanitize(this.ruleCategories()),
trackerPatterns: sanitize(this.ruleTrackerPatterns()),
tagsAny: sanitize(this.ruleTagsAny()),
tagsAll: sanitize(this.ruleTagsAll()),
privacyType: this.rulePrivacyType() as TorrentPrivacyType,
maxRatio: this.ruleMaxRatio() ?? -1,
minSeedTime: this.ruleMinSeedTime() ?? 0,
@@ -297,6 +338,26 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
}
onRulesReorder(event: CdkDragDrop<SeedingRule[]>): void {
const clientId = this.selectedClientId();
if (!clientId) return;
const rules = [...(this.selectedClient()?.seedingRules ?? [])];
moveItemInArray(rules, event.previousIndex, event.currentIndex);
this.clientConfigs.update(configs =>
configs.map(c => c.downloadClientId === clientId ? { ...c, seedingRules: rules } : c)
);
const orderedIds = rules.map(r => r.id!).filter(Boolean);
this.api.reorderSeedingRules(clientId, orderedIds).subscribe({
error: () => {
this.toast.error('Failed to reorder seeding rules');
this.reloadSeedingRules(clientId);
},
});
}
private reloadSeedingRules(clientId: string): void {
this.rulesReloading.set(true);
this.api.getSeedingRules(clientId).subscribe({

View File

@@ -3,6 +3,11 @@ import { TorrentPrivacyType } from './enums';
export interface SeedingRule {
id?: string;
name: string;
categories: string[];
trackerPatterns: string[];
tagsAny?: string[];
tagsAll?: string[];
priority: number;
privacyType: TorrentPrivacyType;
maxRatio: number;
minSeedTime: number;
@@ -40,6 +45,11 @@ export interface DownloadCleanerConfig {
export function createDefaultSeedingRule(): SeedingRule {
return {
name: '',
categories: [],
trackerPatterns: [],
tagsAny: [],
tagsAll: [],
priority: 0,
privacyType: TorrentPrivacyType.Public,
maxRatio: -1,
minSeedTime: 0,

View File

@@ -86,7 +86,7 @@ mytracker.com
<SectionTitle>Seeding Rules</SectionTitle>
<p className={styles.sectionDescription}>
Categories define the cleanup rules for different types of downloads. Each category specifies when downloads should be removed based on ratio and time limits.
Seeding rules define the cleanup criteria for downloads in your download client. Each rule specifies which downloads it applies to (by category, tracker, tags, and privacy type) and when they should be removed based on ratio and time limits. Rules are evaluated in priority order — the first matching rule wins.
</p>
<Note>
@@ -98,10 +98,23 @@ Both Max Ratio and Max Seed Time cannot be disabled (-1) at the same time. At le
</Important>
<ConfigSection
title="Category Name"
title="Rule Name"
>
The name of the download client category to apply these rules to. Must match the category name exactly as configured in your download client.
A human-readable label for this seeding rule. This is a display name only and does not affect rule matching.
**Examples:**
- `TV shows`
- `Movies - private trackers`
- `Music long seed`
</ConfigSection>
<ConfigSection
title="Categories"
>
One or more download client categories this rule applies to. A torrent matches if its category matches **any** entry in the list (case-insensitive). At least one category must be specified.
**Examples:**
- `tv-sonarr`
@@ -110,6 +123,38 @@ The name of the download client category to apply these rules to. Must match the
</ConfigSection>
<ConfigSection
title="Tracker Patterns"
>
Tracker domain suffixes used to filter which torrents this rule applies to. A torrent matches if **any** of its tracker domains end with one of these patterns (case-insensitive). Leave empty to match torrents from any tracker.
**Examples:**
- `tracker.example.com` — matches `https://tracker.example.com/announce`
- `private.org` — matches any tracker whose domain ends with `private.org`
</ConfigSection>
<ConfigSection
title="Tags (Any)"
>
Torrent must have **at least one** of these tags or labels. Leave empty to skip tag filtering.
Supported by **qBittorrent** tags and **Transmission** labels.
</ConfigSection>
<ConfigSection
title="Tags (All)"
>
Torrent must have **all** of these tags or labels. Leave empty to skip tag filtering.
Supported by **qBittorrent** tags and **Transmission** labels.
</ConfigSection>
<ConfigSection
title="Privacy Type"
>

View File

@@ -0,0 +1,272 @@
import { test, expect } from '@playwright/test';
import {
loginAndGetToken,
createDownloadClient,
deleteDownloadClient,
getSeedingRules,
createSeedingRule,
updateSeedingRule,
deleteSeedingRule,
reorderSeedingRules,
} from './helpers/app-api';
test.describe.serial('Seeding Rules API', () => {
let token: string;
let downloadClientId: string;
test.beforeAll(async () => {
token = await loginAndGetToken();
// Create a qBittorrent download client for testing seeding rules
const res = await createDownloadClient(token, {
enabled: false,
name: 'e2e-test-qbit',
typeName: 'qBittorrent',
type: 'Torrent',
host: 'http://localhost:9999',
});
expect(res.status).toBe(201);
const client = await res.json();
downloadClientId = client.id;
});
test.afterAll(async () => {
if (downloadClientId) {
await deleteDownloadClient(token, downloadClientId);
}
});
test('should return empty seeding rules for new client', async () => {
const res = await getSeedingRules(token, downloadClientId);
expect(res.status).toBe(200);
const rules = await res.json();
expect(Array.isArray(rules)).toBe(true);
expect(rules).toHaveLength(0);
});
test('should create a seeding rule with new fields', async () => {
const res = await createSeedingRule(token, downloadClientId, {
name: 'Movies Rule',
categories: ['movies', 'films'],
trackerPatterns: ['tracker.example.com'],
tagsAny: ['hd'],
tagsAll: [],
privacyType: 'Both',
maxRatio: 2.0,
minSeedTime: 0,
maxSeedTime: -1,
deleteSourceFiles: true,
});
expect(res.status).toBe(201);
const rule = await res.json();
expect(rule.name).toBe('Movies Rule');
expect(rule.categories).toEqual(['movies', 'films']);
expect(rule.trackerPatterns).toEqual(['tracker.example.com']);
expect(rule.tagsAny).toEqual(['hd']);
expect(rule.tagsAll).toEqual([]);
expect(rule.priority).toBe(1);
});
test('should auto-assign sequential priorities', async () => {
const res2 = await createSeedingRule(token, downloadClientId, {
name: 'TV Rule',
categories: ['tv'],
privacyType: 'Both',
maxRatio: -1,
minSeedTime: 0,
maxSeedTime: 48,
deleteSourceFiles: true,
});
expect(res2.status).toBe(201);
const rule2 = await res2.json();
expect(rule2.priority).toBe(2);
const res3 = await createSeedingRule(token, downloadClientId, {
name: 'Music Rule',
categories: ['music'],
privacyType: 'Both',
maxRatio: 3.0,
minSeedTime: 0,
maxSeedTime: -1,
deleteSourceFiles: false,
});
expect(res3.status).toBe(201);
const rule3 = await res3.json();
expect(rule3.priority).toBe(3);
});
test('should round-trip new fields through GET', async () => {
const res = await getSeedingRules(token, downloadClientId);
expect(res.status).toBe(200);
const rules = await res.json();
expect(rules).toHaveLength(3);
const moviesRule = rules.find((r: { name: string }) => r.name === 'Movies Rule');
expect(moviesRule).toBeDefined();
expect(moviesRule.categories).toEqual(['movies', 'films']);
expect(moviesRule.trackerPatterns).toEqual(['tracker.example.com']);
expect(moviesRule.priority).toBe(1);
});
test('should reorder seeding rules', async () => {
const getRes = await getSeedingRules(token, downloadClientId);
const rules = await getRes.json();
expect(rules).toHaveLength(3);
// Reverse the order
const reversedIds = rules.map((r: { id: string }) => r.id).reverse();
const reorderRes = await reorderSeedingRules(token, downloadClientId, reversedIds);
expect(reorderRes.status).toBe(204);
// Verify new order
const verifyRes = await getSeedingRules(token, downloadClientId);
const reordered = await verifyRes.json();
expect(reordered[0].priority).toBe(1);
expect(reordered[0].id).toBe(reversedIds[0]);
expect(reordered[1].priority).toBe(2);
expect(reordered[1].id).toBe(reversedIds[1]);
expect(reordered[2].priority).toBe(3);
expect(reordered[2].id).toBe(reversedIds[2]);
});
test('should reject reorder with missing rule IDs', async () => {
const getRes = await getSeedingRules(token, downloadClientId);
const rules = await getRes.json();
// Only send 2 of 3 IDs
const partialIds = rules.slice(0, 2).map((r: { id: string }) => r.id);
const res = await reorderSeedingRules(token, downloadClientId, partialIds);
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('should reject reorder with duplicate IDs', async () => {
const getRes = await getSeedingRules(token, downloadClientId);
const rules = await getRes.json();
const firstId = rules[0].id;
const res = await reorderSeedingRules(token, downloadClientId, [firstId, firstId, rules[1].id]);
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('should reject reorder with invalid rule ID', async () => {
const getRes = await getSeedingRules(token, downloadClientId);
const rules = await getRes.json();
const ids = rules.map((r: { id: string }) => r.id);
ids[0] = '00000000-0000-0000-0000-000000000000';
const res = await reorderSeedingRules(token, downloadClientId, ids);
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('should not change priority on update', async () => {
const getRes = await getSeedingRules(token, downloadClientId);
const rules = await getRes.json();
const rule = rules[0];
const updateRes = await updateSeedingRule(token, rule.id, {
name: 'Updated Name',
categories: rule.categories,
trackerPatterns: rule.trackerPatterns,
privacyType: rule.privacyType,
maxRatio: rule.maxRatio,
minSeedTime: rule.minSeedTime,
maxSeedTime: rule.maxSeedTime,
deleteSourceFiles: rule.deleteSourceFiles,
});
expect(updateRes.status).toBe(200);
const updated = await updateRes.json();
expect(updated.priority).toBe(rule.priority);
});
test('should update tags and persist them', async () => {
const getRes = await getSeedingRules(token, downloadClientId);
const rules = await getRes.json();
const rule = rules[0];
const updateRes = await updateSeedingRule(token, rule.id, {
name: rule.name,
categories: rule.categories,
trackerPatterns: rule.trackerPatterns,
tagsAny: ['updated-tag-1', 'updated-tag-2'],
tagsAll: ['required-tag'],
privacyType: rule.privacyType,
maxRatio: rule.maxRatio,
minSeedTime: rule.minSeedTime,
maxSeedTime: rule.maxSeedTime,
deleteSourceFiles: rule.deleteSourceFiles,
});
expect(updateRes.status).toBe(200);
// Verify tags persisted via GET
const verifyRes = await getSeedingRules(token, downloadClientId);
const updated = await verifyRes.json();
const updatedRule = updated.find((r: { id: string }) => r.id === rule.id);
expect(updatedRule.tagsAny).toEqual(['updated-tag-1', 'updated-tag-2']);
expect(updatedRule.tagsAll).toEqual(['required-tag']);
});
test('should reject empty categories', async () => {
const res = await createSeedingRule(token, downloadClientId, {
name: 'Bad Rule',
categories: [],
privacyType: 'Both',
maxRatio: -1,
minSeedTime: 0,
maxSeedTime: -1,
deleteSourceFiles: true,
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('should reject negative priority', async () => {
const res = await createSeedingRule(token, downloadClientId, {
name: 'Bad Priority Rule',
categories: ['test'],
priority: -1,
privacyType: 'Both',
maxRatio: -1,
minSeedTime: 0,
maxSeedTime: -1,
deleteSourceFiles: true,
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('should strip empty tracker patterns', async () => {
const res = await createSeedingRule(token, downloadClientId, {
name: 'Whitespace Test',
categories: ['test'],
trackerPatterns: ['', ' ', 'valid.com'],
privacyType: 'Both',
maxRatio: 2.0,
minSeedTime: 0,
maxSeedTime: -1,
deleteSourceFiles: true,
});
expect(res.status).toBe(201);
const rule = await res.json();
expect(rule.trackerPatterns).toEqual(['valid.com']);
// Clean up
await deleteSeedingRule(token, rule.id);
});
test('should delete a seeding rule', async () => {
const getRes = await getSeedingRules(token, downloadClientId);
const rules = await getRes.json();
const lastRule = rules[rules.length - 1];
const delRes = await deleteSeedingRule(token, lastRule.id);
expect(delRes.status).toBe(204);
const verifyRes = await getSeedingRules(token, downloadClientId);
const remaining = await verifyRes.json();
expect(remaining).toHaveLength(rules.length - 1);
});
test('should return 404 for non-existent download client', async () => {
const res = await getSeedingRules(token, '00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});

View File

@@ -238,6 +238,44 @@ export async function updateUnlinkedConfig(
});
}
export async function reorderSeedingRules(
accessToken: string,
downloadClientId: string,
orderedIds: string[],
): Promise<Response> {
return fetch(`${API}/api/seeding-rules/${downloadClientId}/reorder`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ orderedIds }),
});
}
// --- Download Client helpers ---
export async function createDownloadClient(
accessToken: string,
client: Record<string, unknown>,
): Promise<Response> {
return fetch(`${API}/api/configuration/download_client`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(client),
});
}
export async function deleteDownloadClient(accessToken: string, clientId: string): Promise<Response> {
return fetch(`${API}/api/configuration/download_client/${clientId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
}
export async function configureOidc(accessToken: string): Promise<void> {
const putRes = await fetch(`${API}/api/account/oidc`, {
method: 'PUT',