using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using NSubstitute; using QBittorrent.Client; using Shouldly; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; public class QBitServiceDCTests : IClassFixture { private readonly QBitServiceFixture _fixture; public QBitServiceDCTests(QBitServiceFixture fixture) { _fixture = fixture; _fixture.ResetMocks(); } public class GetSeedingDownloads_Tests : QBitServiceDCTests { public GetSeedingDownloads_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public async Task ReturnsCompletedTorrents() { // Arrange var sut = _fixture.CreateSut(); var torrentList = new[] { new TorrentInfo { Hash = "hash1", Name = "Torrent 1", State = TorrentState.Uploading }, new TorrentInfo { Hash = "hash2", Name = "Torrent 2", State = TorrentState.Uploading } }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns(torrentList); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash1") .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash2") .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync(Arg.Any()) .Returns(new TorrentProperties { AdditionalData = new Dictionary { { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(false) } } }); // Act var result = await sut.GetSeedingDownloads(); // Assert result.Count.ShouldBe(2); foreach (var item in result) { item.DownloadId.ShouldNotBeNull(); } } [Fact] public async Task SetsIsPrivateCorrectly_WhenPrivate() { // Arrange var sut = _fixture.CreateSut(); var torrentList = new[] { new TorrentInfo { Hash = "hash1", Name = "Private Torrent", State = TorrentState.Uploading } }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns(torrentList); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash1") .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync("hash1") .Returns(new TorrentProperties { AdditionalData = new Dictionary { { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(true) } } }); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].IsPrivate.ShouldBeTrue(); } [Fact] public async Task SetsIsPrivateCorrectly_WhenPublic() { // Arrange var sut = _fixture.CreateSut(); var torrentList = new[] { new TorrentInfo { Hash = "hash1", Name = "Public Torrent", State = TorrentState.Uploading } }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns(torrentList); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash1") .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync("hash1") .Returns(new TorrentProperties { AdditionalData = new Dictionary { { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(false) } } }); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].IsPrivate.ShouldBeFalse(); } [Fact] public async Task ReturnsEmptyList_WhenNoTorrents() { // Arrange var sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns((TorrentInfo[]?)null); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldBeEmpty(); } [Fact] public async Task SkipsTorrentsWithEmptyHash() { // Arrange var sut = _fixture.CreateSut(); var torrentList = new[] { new TorrentInfo { Hash = "", Name = "No Hash", State = TorrentState.Uploading }, new TorrentInfo { Hash = "hash1", Name = "Valid Hash", State = TorrentState.Uploading } }; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns(torrentList); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash1") .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync("hash1") .Returns(new TorrentProperties { AdditionalData = new Dictionary { { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(false) } } }); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].DownloadId.ShouldBe("hash1"); } [Fact] public async Task SkipsTorrent_WhenTrackersAreNull() { // Arrange QBitService sut = _fixture.CreateSut(); TorrentInfo[] torrentList = [ new TorrentInfo { Hash = "hash1", Name = "Deleted Torrent", State = TorrentState.Uploading }, new TorrentInfo { Hash = "hash2", Name = "Live Torrent", State = TorrentState.Uploading } ]; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns(torrentList); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash1") .Returns((IReadOnlyList?)null); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash2") .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync(Arg.Any()) .Returns(PublicProperties()); // Act List result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].DownloadId.ShouldBe("hash2"); } [Fact] public async Task SkipsTorrent_WhenPropertiesAreNull() { // Arrange QBitService sut = _fixture.CreateSut(); TorrentInfo[] torrentList = [ new TorrentInfo { Hash = "hash1", Name = "Deleted Torrent", State = TorrentState.Uploading } ]; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns(torrentList); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash1") .Returns(Array.Empty()); _fixture.ClientWrapper .GetTorrentPropertiesAsync("hash1") .Returns((TorrentProperties?)null); // Act List result = await sut.GetSeedingDownloads(); // Assert result.ShouldBeEmpty(); } [Fact] public async Task KeepsTorrent_WhenOnlyPseudoTrackersArePresent() { // Arrange QBitService sut = _fixture.CreateSut(); TorrentInfo[] torrentList = [ new TorrentInfo { Hash = "hash1", Name = "Trackerless Torrent", State = TorrentState.Uploading } ]; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns(torrentList); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash1") .Returns(new TorrentTracker[] { new() { Url = "** [DHT] **", TrackerStatus = TorrentTrackerStatus.Working }, new() { Url = "** [PeX] **", TrackerStatus = TorrentTrackerStatus.Working }, new() { Url = "** [LSD] **", TrackerStatus = TorrentTrackerStatus.Working } }); _fixture.ClientWrapper .GetTorrentPropertiesAsync("hash1") .Returns(PublicProperties()); // Act List result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].TrackerDomains.ShouldBeEmpty(); result[0].TrackerHealth.ShouldBe(TrackerHealth.Unsupported); } [Fact] public async Task KeepsTorrent_WhenATrackerUrlIsNull() { // Arrange QBitService sut = _fixture.CreateSut(); TorrentInfo[] torrentList = [ new TorrentInfo { Hash = "hash1", Name = "Torrent 1", State = TorrentState.Uploading } ]; _fixture.ClientWrapper .GetTorrentListAsync(Arg.Is(q => q.Filter == TorrentListFilter.Completed)) .Returns(torrentList); _fixture.ClientWrapper .GetTorrentTrackersAsync("hash1") .Returns(new TorrentTracker[] { new() { Url = null!, TrackerStatus = TorrentTrackerStatus.Working }, new() { Url = "http://tracker.example.com/announce", TrackerStatus = TorrentTrackerStatus.Working } }); _fixture.ClientWrapper .GetTorrentPropertiesAsync("hash1") .Returns(PublicProperties()); // Act List result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].TrackerDomains.ShouldHaveSingleItem(); } private static TorrentProperties PublicProperties() => new() { AdditionalData = new Dictionary { { "is_private", Newtonsoft.Json.Linq.JToken.FromObject(false) } } }; } public class FilterDownloadsToBeCleanedAsync_Tests : QBitServiceDCTests { public FilterDownloadsToBeCleanedAsync_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public void MatchesCategories() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty(), false), new QBitItemWrapper(new TorrentInfo { Hash = "hash2", Category = "tv" }, Array.Empty(), false), new QBitItemWrapper(new TorrentInfo { Hash = "hash3", Category = "music" }, Array.Empty(), false) }; var categories = new List { 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 var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories); // Assert result.ShouldNotBeNull(); result.Count.ShouldBe(2); result.ShouldContain(x => x.Category == "movies"); result.ShouldContain(x => x.Category == "tv"); } [Fact] public void IsCaseInsensitive() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "Movies" }, Array.Empty(), false) }; var categories = new List { new QBitSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true } }; // Act var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories); // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); } [Fact] public void SkipsDownloadsWithEmptyHash() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "", Category = "movies" }, Array.Empty(), false), new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty(), false) }; var categories = new List { new QBitSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true } }; // Act var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories); // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); result[0].DownloadId.ShouldBe("hash1"); } [Fact] public void ReturnsEmptyList_WhenNoMatches() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "music" }, Array.Empty(), false) }; var categories = new List { new QBitSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true } }; // Act var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories); // Assert result.ShouldNotBeNull(); result.ShouldBeEmpty(); } } public class CleanDownloadsAsync_Tests : QBitServiceDCTests { public CleanDownloadsAsync_Tests(QBitServiceFixture fixture) : base(fixture) { } private static QBitItemWrapper CreateTorrent(string hash, string category, bool isPrivate, DateTime? lastActivity = null) => new(new TorrentInfo { Hash = hash, Name = $"Test {hash}", Category = category, Ratio = 2.0, SeedingTime = TimeSpan.FromHours(10), LastActivityTime = lastActivity }, Array.Empty(), isPrivate); private static QBitSeedingRule CreateRule(string name, TorrentPrivacyType privacyType) => new() { Name = name, Categories = [name], PrivacyType = privacyType, MaxRatio = 0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = false }; private static ITorrentItemWrapper CreateTorrentWithSeederCount(string hash, int? seederCount) { var torrent = Substitute.For(); torrent.DownloadId.Returns(hash); torrent.Name.Returns($"Test {hash}"); torrent.Category.Returns("movies"); torrent.IsPrivate.Returns(false); torrent.Ratio.Returns(2.0); torrent.SeedingTimeSeconds.Returns((long)TimeSpan.FromHours(10).TotalSeconds); torrent.SeederCount.Returns(seederCount); torrent.TrackerDomains.Returns(Array.Empty()); torrent.Tags.Returns(Array.Empty()); return torrent; } private void SetupDeleteMock() { _fixture.ClientWrapper .DeleteAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); } [Fact] public async Task SkipsPrivateTorrent_WhenRuleIsPublicOnly() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: true) }; var rules = new List { CreateRule("movies", TorrentPrivacyType.Public) }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task CleansPublicTorrent_WhenRuleIsPublicOnly() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: false) }; var rules = new List { CreateRule("movies", TorrentPrivacyType.Public) }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("hash1")), false); } [Fact] public async Task SkipsTorrent_WhenMinimumSeedersNotReached() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrentWithSeederCount("hash1", 4) }; var rule = CreateRule("movies", TorrentPrivacyType.Public); rule.MinSeeders = 5; var rules = new List { rule }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task CleansTorrent_WhenMinimumSeedersReached() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrentWithSeederCount("hash1", 5) }; var rule = CreateRule("movies", TorrentPrivacyType.Public); rule.MinSeeders = 5; var rules = new List { rule }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("hash1")), false); } [Fact] public async Task SkipsTorrent_WhenMinimumSeedersConfiguredAndSeederCountUnavailable() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrentWithSeederCount("hash1", null) }; var rule = CreateRule("movies", TorrentPrivacyType.Public); rule.MinSeeders = 5; var rules = new List { rule }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task CleansTorrent_WhenMinSeedersIsDisabled_AndSeederCountUnavailable() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrentWithSeederCount("hash1", null) }; var rule = CreateRule("movies", TorrentPrivacyType.Public); var rules = new List { rule }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("hash1")), false); } [Fact] public async Task SkipsTorrent_WhenStillActiveWithinInactiveWindow() { var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: false, lastActivity: DateTime.UtcNow.AddDays(-5)) }; var rule = CreateRule("movies", TorrentPrivacyType.Public); rule.MaxInactiveDays = 30; var rules = new List { rule }; await sut.CleanDownloadsAsync(downloads, rules); await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task CleansTorrent_WhenInactiveLongerThanWindow() { var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: false, lastActivity: DateTime.UtcNow.AddDays(-40)) }; var rule = CreateRule("movies", TorrentPrivacyType.Public); rule.MaxInactiveDays = 30; var rules = new List { rule }; await sut.CleanDownloadsAsync(downloads, rules); await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("hash1")), false); } [Fact] public async Task SkipsTorrent_WhenMaxInactiveDaysConfigured_AndLastActivityUnavailable() { var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: false, lastActivity: null) }; var rule = CreateRule("movies", TorrentPrivacyType.Public); rule.MaxInactiveDays = 30; var rules = new List { rule }; await sut.CleanDownloadsAsync(downloads, rules); await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } [Theory] [InlineData(0)] [InlineData(-1)] public async Task SkipsTorrent_WhenMaxInactiveDaysConfigured_AndLastActivityIsUnixEpoch(int epochOffsetSeconds) { QBitService sut = _fixture.CreateSut(); SetupDeleteMock(); List downloads = new() { CreateTorrent("hash1", "movies", isPrivate: false, lastActivity: DateTime.UnixEpoch.AddSeconds(epochOffsetSeconds)) }; QBitSeedingRule rule = CreateRule("movies", TorrentPrivacyType.Public); rule.MaxInactiveDays = 30; List rules = new() { rule }; await sut.CleanDownloadsAsync(downloads, rules); await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task CleansTorrent_WhenMaxInactiveDaysDisabled_RegardlessOfActivity() { var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: false, lastActivity: DateTime.UtcNow) }; var rule = CreateRule("movies", TorrentPrivacyType.Public); var rules = new List { rule }; await sut.CleanDownloadsAsync(downloads, rules); await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("hash1")), false); } [Fact] public async Task SkipsPublicTorrent_WhenRuleIsPrivateOnly() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: false) }; var rules = new List { CreateRule("movies", TorrentPrivacyType.Private) }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task CleansPrivateTorrent_WhenRuleIsPrivateOnly() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: true) }; var rules = new List { CreateRule("movies", TorrentPrivacyType.Private) }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("hash1")), false); } [Fact] public async Task CleansPublicTorrent_WhenRuleIsBoth() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: false) }; var rules = new List { CreateRule("movies", TorrentPrivacyType.Both) }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("hash1")), false); } [Fact] public async Task CleansPrivateTorrent_WhenRuleIsBoth() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("hash1", "movies", isPrivate: true) }; var rules = new List { CreateRule("movies", TorrentPrivacyType.Both) }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("hash1")), false); } [Fact] public async Task MatchesCorrectRule_WhenMultipleRulesForSameCategory() { // Arrange var sut = _fixture.CreateSut(); SetupDeleteMock(); var downloads = new List { CreateTorrent("public-hash", "movies", isPrivate: false), CreateTorrent("private-hash", "movies", isPrivate: true) }; var rules = new List { CreateRule("movies", TorrentPrivacyType.Public), CreateRule("movies", TorrentPrivacyType.Private) }; // Act await sut.CleanDownloadsAsync(downloads, rules); // Assert - both torrents should be cleaned, each matching their respective rule await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("public-hash")), false); await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains("private-hash")), false); } } public class FilterDownloadsToChangeCategoryAsync_Tests : QBitServiceDCTests { public FilterDownloadsToChangeCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public void ExcludesAlreadyTagged_WhenTagModeEnabled() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = true, TargetCategory = "unlinked", Categories = ["movies"] }; var torrentInfo1 = new TorrentInfo { Hash = "hash1", Category = "movies", Tags = new[] { "unlinked" } }; var torrentInfo2 = new TorrentInfo { Hash = "hash2", Category = "movies", Tags = Array.Empty() }; var downloads = new List { new QBitItemWrapper(torrentInfo1, Array.Empty(), false), new QBitItemWrapper(torrentInfo2, Array.Empty(), false) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig); // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); result[0].DownloadId.ShouldBe("hash2"); } [Fact] public void IncludesAll_WhenCategoryModeEnabled() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked", Categories = ["movies"] }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty(), false), new QBitItemWrapper(new TorrentInfo { Hash = "hash2", Category = "movies" }, Array.Empty(), false) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig); // Assert result.ShouldNotBeNull(); result.Count.ShouldBe(2); } [Fact] public void IsCaseInsensitive() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, Categories = ["movies"] }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "Movies" }, Array.Empty(), false) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig); // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); } [Fact] public void SkipsDownloadsWithEmptyHash() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, Categories = ["movies"] }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "", Category = "movies" }, Array.Empty(), false), new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty(), false) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig); // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); result[0].DownloadId.ShouldBe("hash1"); } [Fact] public void ReturnsEmpty_WhenNoCategoriesMatch() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "tv" }, Array.Empty(), false) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] }); // Assert result.ShouldNotBeNull(); result.ShouldBeEmpty(); } } public class CreateCategoryAsync_Tests : QBitServiceDCTests { public CreateCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public async Task CreatesCategory_WhenMissing() { // Arrange var sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetCategoriesAsync() .Returns(new Dictionary()); _fixture.ClientWrapper .AddCategoryAsync("new-category") .Returns(Task.CompletedTask); // Act await sut.CreateCategoryAsync("new-category"); // Assert await _fixture.ClientWrapper.Received(1).AddCategoryAsync("new-category"); } [Fact] public async Task SkipsCreation_WhenCategoryExists() { // Arrange var sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetCategoriesAsync() .Returns(new Dictionary { { "existing", new Category { Name = "existing" } } }); // Act await sut.CreateCategoryAsync("existing"); // Assert await _fixture.ClientWrapper.DidNotReceive().AddCategoryAsync(Arg.Any()); } [Fact] public async Task IsCaseInsensitive() { // Arrange var sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetCategoriesAsync() .Returns(new Dictionary { { "existing", new Category { Name = "Existing" } } }); // Act await sut.CreateCategoryAsync("existing"); // Assert await _fixture.ClientWrapper.DidNotReceive().AddCategoryAsync(Arg.Any()); } } public class DeleteDownload_Tests : QBitServiceDCTests { public DeleteDownload_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public async Task CallsClientDelete() { // Arrange var sut = _fixture.CreateSut(); const string hash = "test-hash"; var mockTorrent = Substitute.For(); mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .DeleteAsync(Arg.Is>(h => h.Contains(hash)), true) .Returns(Task.CompletedTask); // Act await sut.DeleteDownload(mockTorrent, true); // Assert await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Is>(h => h.Contains(hash)), true); } [Fact] public async Task DeletesWithData() { // Arrange var sut = _fixture.CreateSut(); const string hash = "test-hash"; var mockTorrent = Substitute.For(); mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .DeleteAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); // Act await sut.DeleteDownload(mockTorrent, true); // Assert await _fixture.ClientWrapper.Received(1) .DeleteAsync(Arg.Any>(), true); } } public class StopDownload_Tests : QBitServiceDCTests { public StopDownload_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public async Task CallsClientPause() { // Arrange QBitService sut = _fixture.CreateSut(); const string hash = "test-hash"; ITorrentItemWrapper mockTorrent = Substitute.For(); mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .PauseAsync(Arg.Is>(h => h.Contains(hash))) .Returns(Task.CompletedTask); // Act await sut.StopDownload(mockTorrent); // Assert await _fixture.ClientWrapper.Received(1) .PauseAsync(Arg.Is>(h => h.Contains(hash))); } [Fact] public async Task DoesNotDeleteTheTorrent() { // Arrange QBitService sut = _fixture.CreateSut(); ITorrentItemWrapper mockTorrent = Substitute.For(); mockTorrent.DownloadId.Returns("test-hash"); _fixture.ClientWrapper .PauseAsync(Arg.Any>()) .Returns(Task.CompletedTask); // Act await sut.StopDownload(mockTorrent); // Assert await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } } public class ResumeDownload_Tests : QBitServiceDCTests { public ResumeDownload_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public async Task CallsClientResume() { // Arrange QBitService sut = _fixture.CreateSut(); const string hash = "test-hash"; ITorrentItemWrapper mockTorrent = Substitute.For(); mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .ResumeAsync(Arg.Is>(h => h.Contains(hash))) .Returns(Task.CompletedTask); // Act await sut.ResumeDownload(mockTorrent); // Assert await _fixture.ClientWrapper.Received(1) .ResumeAsync(Arg.Is>(h => h.Contains(hash))); } [Fact] public async Task DoesNotDeleteTheTorrent() { // Arrange QBitService sut = _fixture.CreateSut(); ITorrentItemWrapper mockTorrent = Substitute.For(); mockTorrent.DownloadId.Returns("test-hash"); _fixture.ClientWrapper .ResumeAsync(Arg.Any>()) .Returns(Task.CompletedTask); // Act await sut.ResumeDownload(mockTorrent); // Assert await _fixture.ClientWrapper.DidNotReceive() .DeleteAsync(Arg.Any>(), Arg.Any()); } } public class IsStopped_Tests : QBitServiceDCTests { public IsStopped_Tests(QBitServiceFixture fixture) : base(fixture) { } [Theory] [InlineData(TorrentState.PausedUpload)] [InlineData(TorrentState.PausedDownload)] public void ReturnsTrueForPausedStates(TorrentState state) { // Arrange QBitItemWrapper wrapper = new QBitItemWrapper( new TorrentInfo { Hash = "hash1", State = state }, Array.Empty(), false); // Act & Assert wrapper.IsStopped.ShouldBeTrue(); } [Theory] [InlineData(TorrentState.Uploading)] [InlineData(TorrentState.Downloading)] [InlineData(TorrentState.StalledUpload)] public void ReturnsFalseForRunningStates(TorrentState state) { // Arrange QBitItemWrapper wrapper = new QBitItemWrapper( new TorrentInfo { Hash = "hash1", State = state }, Array.Empty(), false); // Act & Assert wrapper.IsStopped.ShouldBeFalse(); } } public class ChangeTorrentCategoryAsync_Tests : QBitServiceDCTests { public ChangeTorrentCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public async Task CategoryMode_SetsCategory_AndPublishes() { var sut = _fixture.CreateSut(); var torrent = Substitute.For(); torrent.DownloadId.Returns("hash1"); torrent.Name.Returns("Test"); torrent.Category.Returns("movies"); await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false); await _fixture.ClientWrapper.Received(1) .SetTorrentCategoryAsync(Arg.Is>(h => h.Contains("hash1")), "cleanuparr-dead"); await _fixture.ClientWrapper.DidNotReceive() .AddTorrentTagAsync(Arg.Any>(), Arg.Any()); await _fixture.EventPublisher.Received(1) .PublishCategoryChanged("movies", "cleanuparr-dead", false); } [Fact] public async Task TagMode_AddsTag_AndPublishes() { var sut = _fixture.CreateSut(); var torrent = Substitute.For(); torrent.DownloadId.Returns("hash1"); torrent.Name.Returns("Test"); torrent.Category.Returns("movies"); await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: true); await _fixture.ClientWrapper.Received(1) .AddTorrentTagAsync(Arg.Is>(h => h.Contains("hash1")), "cleanuparr-dead"); await _fixture.ClientWrapper.DidNotReceive() .SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); await _fixture.EventPublisher.Received(1) .PublishCategoryChanged("movies", "cleanuparr-dead", true); } [Fact] public async Task DryRun_SkipsClientCall_ButPublishesEvent() { var sut = _fixture.CreateSut(); var torrent = Substitute.For(); torrent.DownloadId.Returns("hash1"); torrent.Name.Returns("Test"); torrent.Category.Returns("movies"); _fixture.DryRunInterceptor .InterceptAsync(Arg.Any>(), Arg.Any()) .Returns(Task.CompletedTask); await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false); await _fixture.ClientWrapper.DidNotReceive() .SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); await _fixture.ClientWrapper.DidNotReceive() .AddTorrentTagAsync(Arg.Any>(), Arg.Any()); await _fixture.EventPublisher.Received(1) .PublishCategoryChanged("movies", "cleanuparr-dead", false); } } public class ChangeCategoryForNoHardLinksAsync_Tests : QBitServiceDCTests { public ChangeCategoryForNoHardLinksAsync_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public async Task NullDownloads_DoesNothing() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; // Act await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig); // Assert - no exceptions thrown await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task EmptyDownloads_DoesNothing() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; // Act await sut.ChangeCategoryForNoHardLinksAsync(new List(), unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task MissingHash_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task MissingName_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task MissingCategory_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "", SavePath = "/downloads" }, Array.Empty(), false) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task NoFiles_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns((IReadOnlyList?)null); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task NoHardlinks_ChangesCategory() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(0); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.Received(1) .SetTorrentCategoryAsync(Arg.Is>(h => h.Contains("hash1")), "unlinked"); } [Fact] public async Task NoHardlinks_TagMode_AddsTag() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = true, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(0); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.Received(1) .AddTorrentTagAsync(Arg.Is>(h => h.Contains("hash1")), "unlinked"); await _fixture.ClientWrapper.DidNotReceive() .SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task HasHardlinks_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(2); // Has hardlinks // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task FileNotFound_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(-1); // Error // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task SkippedFiles_IgnoredInCheck() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .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 .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(0); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert _fixture.HardLinkFileService.Received(1) .GetHardLinkCount(Arg.Any(), Arg.Any()); // Only called for file2.mkv } [Fact] public async Task FileWithNullIndex_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = null, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } }); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); } [Fact] public async Task PublishesCategoryChangedEvent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = false, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(0); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert - EventPublisher is not mocked, so we just verify the method completed await _fixture.ClientWrapper.Received(1) .SetTorrentCategoryAsync(Arg.Is>(h => h.Contains("hash1")), "unlinked"); } [Fact] public async Task PublishesCategoryChangedEvent_WithTagFlag() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), UseTag = true, TargetCategory = "unlinked" }; var downloads = new List { new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Name = "Test", Category = "movies", SavePath = "/downloads" }, Array.Empty(), false) }; _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = 0, Name = "file1.mkv", Priority = TorrentContentPriority.Normal } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(0); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert - EventPublisher is not mocked, so we just verify the method completed await _fixture.ClientWrapper.Received(1) .AddTorrentTagAsync(Arg.Is>(h => h.Contains("hash1")), "unlinked"); } } public class GetClaimedPaths_Tests : QBitServiceDCTests { public GetClaimedPaths_Tests(QBitServiceFixture fixture) : base(fixture) { } [Fact] public async Task UsesFileList_WhenDisplayNameDivergesFromDisk() { var sut = _fixture.CreateSut(); var wrapper = new QBitItemWrapper( new TorrentInfo { Hash = "hash1", Name = "Renamed Display Name", SavePath = "/downloads" }, Array.Empty(), false); _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = 0, Name = "actual-folder/data.bin", Priority = TorrentContentPriority.Normal } }); IReadOnlyList claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper }); claimed.ShouldContain("/downloads/actual-folder"); claimed.ShouldNotContain("/downloads/Renamed Display Name"); } [Fact] public async Task FallsBackToSavePathAndName_WhenFileListUnavailable() { // no files returned (e.g. metadata not yet fetched) — claim save path + name. var sut = _fixture.CreateSut(); var wrapper = new QBitItemWrapper( new TorrentInfo { Hash = "hash1", Name = "some-show", SavePath = "/downloads" }, Array.Empty(), false); _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(Array.Empty()); IReadOnlyList claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper }); claimed.ShouldContain("/downloads/some-show"); } [Fact] public async Task MultiFileSharingFolder_ClaimsSingleRoot() { // both files live under one folder → one claimed entry, not the deep file paths. var sut = _fixture.CreateSut(); var wrapper = new QBitItemWrapper( new TorrentInfo { Hash = "hash1", Name = "show", SavePath = "/downloads" }, Array.Empty(), false); _fixture.ClientWrapper .GetTorrentContentsAsync("hash1") .Returns(new[] { new TorrentContent { Index = 0, Name = "show/file1.mkv", Priority = TorrentContentPriority.Normal }, new TorrentContent { Index = 1, Name = "show/file2.mkv", Priority = TorrentContentPriority.Normal } }); IReadOnlyList claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper }); claimed.ShouldContain("/downloads/show"); claimed.Count(p => p == "/downloads/show").ShouldBe(1); claimed.ShouldNotContain("/downloads/show/file1.mkv"); } } }