using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.Deluge.Response; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using NSubstitute; using NSubstitute.ExceptionExtensions; using Shouldly; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; public class DelugeServiceDCTests : IClassFixture { private readonly DelugeServiceFixture _fixture; public DelugeServiceDCTests(DelugeServiceFixture fixture) { _fixture = fixture; _fixture.ResetMocks(); } public class GetSeedingDownloads_Tests : DelugeServiceDCTests { public GetSeedingDownloads_Tests(DelugeServiceFixture fixture) : base(fixture) { } [Fact] public async Task FiltersSeedingState() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = DelugeState.Seeding, Private = false, Trackers = new List(), DownloadLocation = "/downloads" }, new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = DelugeState.Downloading, Private = false, Trackers = new List(), DownloadLocation = "/downloads" }, new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = DelugeState.Seeding, Private = false, Trackers = new List(), DownloadLocation = "/downloads" } }; _fixture.ClientWrapper .GetStatusForAllTorrents() .Returns(downloads); // Act var result = await sut.GetSeedingDownloads(); // Assert result.Count.ShouldBe(2); foreach (var item in result) { item.Hash.ShouldNotBeNull(); } } [Fact] public async Task ReturnsEmptyList_WhenNull() { // Arrange var sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetStatusForAllTorrents() .Returns((List?)null); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldBeEmpty(); } [Fact] public async Task SkipsTorrentsWithEmptyHash() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DownloadStatus { Hash = "", Name = "No Hash", State = DelugeState.Seeding, Private = false, Trackers = new List(), DownloadLocation = "/downloads" }, new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = DelugeState.Seeding, Private = false, Trackers = new List(), DownloadLocation = "/downloads" } }; _fixture.ClientWrapper .GetStatusForAllTorrents() .Returns(downloads); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].Hash.ShouldBe("hash1"); } [Fact] public async Task IncludesPausedFinishedTorrents() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DownloadStatus { Hash = "hash1", Name = "Paused finished", State = DelugeState.Paused, IsFinished = true, Private = false, Trackers = new List(), DownloadLocation = "/downloads" } }; _fixture.ClientWrapper .GetStatusForAllTorrents() .Returns(downloads); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].Hash.ShouldBe("hash1"); } [Fact] public async Task IncludesQueuedFinishedTorrents() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DownloadStatus { Hash = "hash1", Name = "Queued finished", State = DelugeState.Queued, IsFinished = true, Private = false, Trackers = new List(), DownloadLocation = "/downloads" } }; _fixture.ClientWrapper .GetStatusForAllTorrents() .Returns(downloads); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].Hash.ShouldBe("hash1"); } [Fact] public async Task ExcludesPausedNotFinished() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DownloadStatus { Hash = "hash1", Name = "Paused mid-download", State = DelugeState.Paused, IsFinished = false, Private = false, Trackers = new List(), DownloadLocation = "/downloads" } }; _fixture.ClientWrapper .GetStatusForAllTorrents() .Returns(downloads); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldBeEmpty(); } [Fact] public async Task ExcludesQueuedNotFinished() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DownloadStatus { Hash = "hash1", Name = "Queued mid-download", State = DelugeState.Queued, IsFinished = false, Private = false, Trackers = new List(), DownloadLocation = "/downloads" } }; _fixture.ClientWrapper .GetStatusForAllTorrents() .Returns(downloads); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldBeEmpty(); } [Fact] public async Task IncludesSeedingRegardlessOfIsFinished() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DownloadStatus { Hash = "hash1", Name = "Seeding without IsFinished flag", State = DelugeState.Seeding, IsFinished = false, Private = false, Trackers = new List(), DownloadLocation = "/downloads" } }; _fixture.ClientWrapper .GetStatusForAllTorrents() .Returns(downloads); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].Hash.ShouldBe("hash1"); } } public class FilterDownloadsToBeCleanedAsync_Tests : DelugeServiceDCTests { public FilterDownloadsToBeCleanedAsync_Tests(DelugeServiceFixture fixture) : base(fixture) { } [Fact] public void MatchesCategories() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }), new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List(), DownloadLocation = "/downloads" }), new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List(), DownloadLocation = "/downloads" }) }; var categories = new List { 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 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 DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; var categories = new List { new DelugeSeedingRule { 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 ReturnsEmptyList_WhenNoMatches() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List(), DownloadLocation = "/downloads" }) }; var categories = new List { new DelugeSeedingRule { 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 FilterDownloadsToChangeCategoryAsync_Tests : DelugeServiceDCTests { public FilterDownloadsToChangeCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture) { } [Fact] public void FiltersCorrectly() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }), new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List(), DownloadLocation = "/downloads" }) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] }); // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); result[0].Hash.ShouldBe("hash1"); } [Fact] public void IsCaseInsensitive() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] }); // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); } [Fact] public void SkipsDownloadsWithEmptyHash() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }), new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] }); // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); result[0].Hash.ShouldBe("hash1"); } [Fact] public void ReturnsEmpty_WhenNoCategoriesMatch() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "tv", Trackers = new List(), DownloadLocation = "/downloads" }) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] }); // Assert result.ShouldNotBeNull(); result.ShouldBeEmpty(); } } public class CreateCategoryAsync_Tests : DelugeServiceDCTests { public CreateCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture) { } [Fact] public async Task CreatesLabel_WhenMissing() { // Arrange var sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetLabels() .Returns(new List()); _fixture.ClientWrapper .CreateLabel("new-label") .Returns(Task.CompletedTask); // Act await sut.CreateCategoryAsync("new-label"); // Assert await _fixture.ClientWrapper.Received(1).CreateLabel("new-label"); } [Fact] public async Task SkipsCreation_WhenLabelExists() { // Arrange var sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetLabels() .Returns(new List { "existing" }); // Act await sut.CreateCategoryAsync("existing"); // Assert await _fixture.ClientWrapper.DidNotReceive().CreateLabel(Arg.Any()); } [Fact] public async Task IsCaseInsensitive() { // Arrange var sut = _fixture.CreateSut(); _fixture.ClientWrapper .GetLabels() .Returns(new List { "Existing" }); // Act await sut.CreateCategoryAsync("existing"); // Assert await _fixture.ClientWrapper.DidNotReceive().CreateLabel(Arg.Any()); } } public class DeleteDownload_Tests : DelugeServiceDCTests { public DeleteDownload_Tests(DelugeServiceFixture fixture) : base(fixture) { } [Fact] public async Task CallsClientDelete() { // Arrange var sut = _fixture.CreateSut(); const string hash = "TEST-HASH"; var mockTorrent = Substitute.For(); mockTorrent.Hash.Returns(hash); _fixture.ClientWrapper .DeleteTorrents(Arg.Is>(h => h.Contains("test-hash")), true) .Returns(Task.CompletedTask); // Act await sut.DeleteDownload(mockTorrent, true); // Assert await _fixture.ClientWrapper.Received(1) .DeleteTorrents(Arg.Is>(h => h.Contains("test-hash")), true); } [Fact] public async Task NormalizesHashToLowercase() { // Arrange var sut = _fixture.CreateSut(); const string hash = "UPPERCASE-HASH"; var mockTorrent = Substitute.For(); mockTorrent.Hash.Returns(hash); _fixture.ClientWrapper .DeleteTorrents(Arg.Any>(), true) .Returns(Task.CompletedTask); // Act await sut.DeleteDownload(mockTorrent, true); // Assert await _fixture.ClientWrapper.Received(1) .DeleteTorrents(Arg.Is>(h => h.Contains("uppercase-hash")), true); } [Fact] public async Task CallsClientDeleteWithoutSourceFiles() { // Arrange var sut = _fixture.CreateSut(); const string hash = "TEST-HASH"; var mockTorrent = Substitute.For(); mockTorrent.Hash.Returns(hash); _fixture.ClientWrapper .DeleteTorrents(Arg.Is>(h => h.Contains("test-hash")), false) .Returns(Task.CompletedTask); // Act await sut.DeleteDownload(mockTorrent, false); // Assert await _fixture.ClientWrapper.Received(1) .DeleteTorrents(Arg.Is>(h => h.Contains("test-hash")), false); } } public class ChangeCategoryForNoHardLinksAsync_Tests : DelugeServiceDCTests { public ChangeCategoryForNoHardLinksAsync_Tests(DelugeServiceFixture fixture) : base(fixture) { } [Fact] public async Task NullDownloads_DoesNothing() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; // Act await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); } [Fact] public async Task EmptyDownloads_DoesNothing() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; // Act await sut.ChangeCategoryForNoHardLinksAsync(new List(), unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); } [Fact] public async Task MissingHash_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); } [Fact] public async Task MissingName_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); } [Fact] public async Task MissingCategory_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "", Trackers = new List(), DownloadLocation = "/downloads" }) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); } [Fact] public async Task ExceptionGettingFiles_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; _fixture.ClientWrapper .GetTorrentFiles("hash1") .Throws(new InvalidOperationException("Failed to get files")); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); } [Fact] public async Task NoHardlinks_ChangesLabel() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; _fixture.ClientWrapper .GetTorrentFiles("hash1") .Returns(new DelugeContents { Contents = new Dictionary { { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(0); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.Received(1) .SetTorrentLabel("hash1", "unlinked"); } [Fact] public async Task HasHardlinks_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; _fixture.ClientWrapper .GetTorrentFiles("hash1") .Returns(new DelugeContents { Contents = new Dictionary { { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(2); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); } [Fact] public async Task FileNotFound_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; _fixture.ClientWrapper .GetTorrentFiles("hash1") .Returns(new DelugeContents { Contents = new Dictionary { { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } } }); _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(-1); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); } [Fact] public async Task SkippedFiles_IgnoredInCheck() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; _fixture.ClientWrapper .GetTorrentFiles("hash1") .Returns(new DelugeContents { Contents = new Dictionary { { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "file1.mkv" } }, { "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "file2.mkv" } } } }); _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()); } [Fact] public async Task PublishesCategoryChangedEvent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) }; _fixture.ClientWrapper .GetTorrentFiles("hash1") .Returns(new DelugeContents { Contents = new Dictionary { { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } } }); _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) .SetTorrentLabel("hash1", "unlinked"); } } }