using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using NSubstitute; using Transmission.API.RPC.Entity; using Shouldly; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; public class TransmissionServiceDCTests : IClassFixture { private readonly TransmissionServiceFixture _fixture; public TransmissionServiceDCTests(TransmissionServiceFixture fixture) { _fixture = fixture; _fixture.ResetMocks(); } public class GetSeedingDownloads_Tests : TransmissionServiceDCTests { public GetSeedingDownloads_Tests(TransmissionServiceFixture fixture) : base(fixture) { } [Fact] public async Task FiltersStatus5And6() { // Arrange var sut = _fixture.CreateSut(); var torrents = new TransmissionTorrents { Torrents = new[] { new TorrentInfo { HashString = "hash1", Name = "Torrent 1", Status = 5 }, // Seeding new TorrentInfo { HashString = "hash2", Name = "Torrent 2", Status = 4 }, // Downloading new TorrentInfo { HashString = "hash3", Name = "Torrent 3", Status = 6 } // Seeding } }; _fixture.ClientWrapper .TorrentGetAsync(Arg.Any(), Arg.Any()) .Returns(torrents); // 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 .TorrentGetAsync(Arg.Any(), Arg.Any()) .Returns((TransmissionTorrents?)null); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldBeEmpty(); } [Fact] public async Task SkipsTorrentsWithEmptyHash() { // Arrange var sut = _fixture.CreateSut(); var torrents = new TransmissionTorrents { Torrents = new[] { new TorrentInfo { HashString = "", Name = "No Hash", Status = 5 }, new TorrentInfo { HashString = "hash1", Name = "Valid Hash", Status = 5 } } }; _fixture.ClientWrapper .TorrentGetAsync(Arg.Any(), Arg.Any()) .Returns(torrents); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldHaveSingleItem(); result[0].Hash.ShouldBe("hash1"); } [Fact] public async Task ReturnsEmptyList_WhenTorrentsNull() { // Arrange var sut = _fixture.CreateSut(); var torrents = new TransmissionTorrents { Torrents = null }; _fixture.ClientWrapper .TorrentGetAsync(Arg.Any(), Arg.Any()) .Returns(torrents); // Act var result = await sut.GetSeedingDownloads(); // Assert result.ShouldBeEmpty(); } } public class FilterDownloadsToBeCleanedAsync_Tests : TransmissionServiceDCTests { public FilterDownloadsToBeCleanedAsync_Tests(TransmissionServiceFixture fixture) : base(fixture) { } [Fact] public void MatchesCategories() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }), new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" }), new TransmissionItemWrapper(new TorrentInfo { HashString = "hash3", DownloadDir = "/downloads/music" }) }; var categories = new List { 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 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 TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" }) }; var categories = new List { new TransmissionSeedingRule { 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 TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" }) }; var categories = new List { new TransmissionSeedingRule { 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 : TransmissionServiceDCTests { public FilterDownloadsToChangeCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture) { } [Fact] public void FiltersCorrectly() { // Arrange var sut = _fixture.CreateSut(); var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }), new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" }) }; // 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 TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" }) }; // 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 TransmissionItemWrapper(new TorrentInfo { HashString = "", DownloadDir = "/downloads/movies" }), new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }) }; // 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 TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/tv" }) }; // Act var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] }); // Assert result.ShouldNotBeNull(); result.ShouldBeEmpty(); } } public class CreateCategoryAsync_Tests : TransmissionServiceDCTests { public CreateCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture) { } [Fact] public async Task IsNoOp() { // Arrange var sut = _fixture.CreateSut(); // Act await sut.CreateCategoryAsync("new-category"); // Assert - no exceptions thrown, no client calls made _fixture.ClientWrapper.ReceivedCalls().ToList().ForEach(call => { // Allow any calls that were set up, just verify no unexpected calls }); } } public class DeleteDownload_Tests : TransmissionServiceDCTests { public DeleteDownload_Tests(TransmissionServiceFixture fixture) : base(fixture) { } [Fact] public async Task GetsIdFromHash_ThenDeletes() { // Arrange var sut = _fixture.CreateSut(); const string hash = "test-hash"; var torrentInfo = new TorrentInfo { Id = 123, HashString = hash }; var torrentWrapper = new TransmissionItemWrapper(torrentInfo); _fixture.ClientWrapper .TorrentRemoveAsync(Arg.Is(ids => ids.Contains(123)), true) .Returns(Task.CompletedTask); // Act await sut.DeleteDownload(torrentWrapper, true); // Assert await _fixture.ClientWrapper.Received(1) .TorrentRemoveAsync(Arg.Is(ids => ids.Contains(123)), true); } [Fact] public async Task HandlesNotFound() { // Arrange var sut = _fixture.CreateSut(); const string hash = "nonexistent-hash"; var torrentInfo = new TorrentInfo { Id = 456, HashString = hash }; var torrentWrapper = new TransmissionItemWrapper(torrentInfo); _fixture.ClientWrapper .TorrentRemoveAsync(Arg.Is(ids => ids.Contains(456)), true) .Returns(Task.CompletedTask); // Act await sut.DeleteDownload(torrentWrapper, true); // Assert await _fixture.ClientWrapper.Received(1) .TorrentRemoveAsync(Arg.Is(ids => ids.Contains(456)), true); } [Fact] public async Task DeletesWithData() { // Arrange var sut = _fixture.CreateSut(); const string hash = "test-hash"; var torrentInfo = new TorrentInfo { Id = 123, HashString = hash }; var torrentWrapper = new TransmissionItemWrapper(torrentInfo); _fixture.ClientWrapper .TorrentRemoveAsync(Arg.Any(), true) .Returns(Task.CompletedTask); // Act await sut.DeleteDownload(torrentWrapper, true); // Assert await _fixture.ClientWrapper.Received(1) .TorrentRemoveAsync(Arg.Any(), true); } } public class ChangeCategoryForNoHardLinksAsync_Tests : TransmissionServiceDCTests { public ChangeCategoryForNoHardLinksAsync_Tests(TransmissionServiceFixture 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().TorrentSetLocationAsync(Arg.Any(), 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().TorrentSetLocationAsync(Arg.Any(), 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 TransmissionItemWrapper(new TorrentInfo { HashString = "", Name = "Test", DownloadDir = "/downloads" }) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any(), 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 TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "", DownloadDir = "/downloads" }) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task MissingDownloadDir_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "" }) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task MissingFiles_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "/downloads", Files = null }) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task MissingFileStats_SkipsTorrent() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "/downloads", Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } }, FileStats = null }) }; // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task NoHardlinks_ChangesLocation() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var baseDownloadDir = Path.Combine("downloads", "movies"); var expectedNewLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/'])); var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { Id = 123, HashString = "hash1", Name = "Test", DownloadDir = baseDownloadDir, Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } }, FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } } }) }; _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(0); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.Received(1) .TorrentSetLocationAsync(Arg.Is(ids => ids.Contains(123)), expectedNewLocation, true); } [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 TransmissionItemWrapper(new TorrentInfo { Id = 123, HashString = "hash1", Name = "Test", DownloadDir = "/downloads/movies", Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } }, FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } } }) }; _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(2); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any(), 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 TransmissionItemWrapper(new TorrentInfo { Id = 123, HashString = "hash1", Name = "Test", DownloadDir = "/downloads/movies", Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } }, FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } } }) }; _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(-1); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task UnwantedFiles_IgnoredInCheck() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { Id = 123, HashString = "hash1", Name = "Test", DownloadDir = "/downloads/movies", Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" }, new TransmissionTorrentFiles { Name = "file2.mkv" } }, FileStats = new[] { new TransmissionTorrentFileStats { Wanted = false }, new TransmissionTorrentFileStats { Wanted = true } } }) }; _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 baseDownloadDir = Path.Combine("downloads", "movies"); var expectedNewLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/'])); var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { Id = 123, HashString = "hash1", Name = "Test", DownloadDir = baseDownloadDir, Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } }, FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } } }) }; _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) .TorrentSetLocationAsync(Arg.Is(ids => ids.Contains(123)), expectedNewLocation, true); } [Fact] public async Task AppendsTargetCategoryToBasePath() { // Arrange var sut = _fixture.CreateSut(); var unlinkedConfig = new UnlinkedConfig { Id = Guid.NewGuid(), TargetCategory = "unlinked" }; var baseDownloadDir = Path.Combine("downloads", "movies", "subfolder"); var expectedNewLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/'])); var downloads = new List { new TransmissionItemWrapper(new TorrentInfo { Id = 123, HashString = "hash1", Name = "Test", DownloadDir = baseDownloadDir, Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } }, FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } } }) }; _fixture.HardLinkFileService .GetHardLinkCount(Arg.Any(), Arg.Any()) .Returns(0); // Act await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); // Assert await _fixture.ClientWrapper.Received(1) .TorrentSetLocationAsync(Arg.Is(ids => ids.Contains(123)), expectedNewLocation, true); } } }