diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs new file mode 100644 index 0000000..88ca92e --- /dev/null +++ b/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs @@ -0,0 +1,705 @@ +using AllDebridNET; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; +using RdtClient.Service.Services.TorrentClients; +using File = AllDebridNET.File; + +namespace RdtClient.Service.Test.Services.TorrentClients; + +public class AllDebridTorrentClientTest +{ + private static readonly Magnet Magnet1HalfDownloaded = new() + { + Id = 1, + Filename = "some-files", + Hash = "hash-1", + Status = "Downloading", + StatusCode = 1, + Downloaded = 50, + Size = 100, + Uploaded = 0, + Seeders = 1, + DownloadSpeed = 5, + UploadSpeed = 0, + UploadDate = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero).Second, + CompletionDate = 0 + }; + + private static readonly Magnet Magnet1Finished = new() + { + Id = Magnet1HalfDownloaded.Id, + Filename = Magnet1HalfDownloaded.Filename, + Hash = Magnet1HalfDownloaded.Hash, + Status = "Ready", + StatusCode = 4, + Size = Magnet1HalfDownloaded.Size, + UploadDate = Magnet1HalfDownloaded.UploadDate, + CompletionDate = new DateTimeOffset(2020, 1, 1, 1, 0, 0, TimeSpan.Zero).Second + }; + + private static readonly Magnet Magnet2QuarterDownloaded = new() + { + Id = 2, + Filename = "some-other-files", + Hash = "hash-2", + Status = "Downloading", + StatusCode = 1, + Downloaded = 100, + Size = 400, + Uploaded = 0, + Seeders = 1, + DownloadSpeed = 5, + UploadSpeed = 0, + UploadDate = new DateTimeOffset(2020, 1, 1, 0, 5, 0, TimeSpan.Zero).Second, + CompletionDate = 0 + }; + + private static readonly Magnet Magnet2Finished = new() + { + Id = Magnet2QuarterDownloaded.Id, + Filename = Magnet2QuarterDownloaded.Filename, + Hash = Magnet2QuarterDownloaded.Hash, + Status = "Ready", + StatusCode = 4, + Size = Magnet2QuarterDownloaded.Size, + UploadDate = Magnet2QuarterDownloaded.UploadDate, + CompletionDate = new DateTimeOffset(2020, 1, 1, 1, 5, 0, TimeSpan.Zero).Second + }; + + private static readonly List IncludeExcludeFiles = + [ + new() + { + FolderOrFileName = "include.txt", + Size = 1, + DownloadLink = "https://fake.url/include.txt" + }, + new() + { + FolderOrFileName = "exclude.txt", + Size = 1, + DownloadLink = "https://fake.url/exclude.txt" + }, + new() + { + FolderOrFileName = "include-folder", + SubNodes = + [ + new File + { + FolderOrFileName = "include-folder-exclude-file.txt", + Size = 1, + DownloadLink = "https://fake.url/include-folder-exclude-file.txt" + }, + new File + { + FolderOrFileName = "include-folder-include-file.txt", + Size = 1, + DownloadLink = "https://fake.url/include-folder-include-file.txt" + } + ] + }, + new() + { + FolderOrFileName = "exclude-folder", + SubNodes = + [ + new File + { + FolderOrFileName = "exclude-folder-exclude-file.txt", + Size = 1, + DownloadLink = "https://fake.url/exclude-folder-exclude-file.txt" + }, + new File + { + FolderOrFileName = "exclude-folder-include-file.txt", + Size = 1, + DownloadLink = "https://fake.url/exclude-folder-include-file.txt" + } + ] + } + ]; + + [Fact] + public async Task GetTorrents_WhenFullSyncNoTorrents_ReturnsEmptyList() + { + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [], + Fullsync = true + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTorrents_WhenPartialSyncNoTorrents_ReturnsEmptyList() + { + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [], + Fullsync = true + }) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 2, + Magnets = [], + Fullsync = false + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTorrents_WhenTorrentsFullSync_ReturnsOnlyTorrentsFromResponse() + { + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [Magnet1Finished], + Fullsync = true + }) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 2, + Magnets = [Magnet2Finished], + Fullsync = true + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + + // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // so when the second call `_cache.Add`s, it also affects `firstResult`. + // `.ToList()` clones it so it won't be changed by the second call + var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); + var secondResult = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.Equal(1, firstResult.Count); + Assert.Equal(Magnet1Finished.Id.ToString(), firstResult.First().Id); + Assert.Equal(1, secondResult.Count); + Assert.Equal(Magnet2Finished.Id.ToString(), secondResult.First().Id); + } + + [Fact] + public async Task GetTorrents_WhenPartialSyncResponseHasNewTorrent_ReturnsCachedAndNewTorrents() + { + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [Magnet1Finished], + Fullsync = true + }) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 2, + Magnets = [Magnet2Finished], + Fullsync = false + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + + // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // so when the second call `_cache.Add`s, it also affects `firstResult`. + // `.ToList()` clones it so it won't be changed by the second call + var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); + var secondResult = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.Equal(1, firstResult.Count); + Assert.Equal(Magnet1Finished.Id.ToString(), firstResult[0].Id); + Assert.Equal(2, secondResult.Count); + Assert.Contains(secondResult, t => t.Id == Magnet1Finished.Id.ToString()); + Assert.Contains(secondResult, t => t.Id == Magnet2Finished.Id.ToString()); + } + + [Fact] + public async Task GetTorrents_WhenPartialSyncResponseHasUpdate_ReturnsCachedAndUpdatedTorrents() + { + // Double check the fakes are as we expect + Assert.Equal(Magnet1Finished.Id, Magnet1HalfDownloaded.Id); + Assert.NotEqual(Magnet1Finished.Status, Magnet1HalfDownloaded.Status); + + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [Magnet1HalfDownloaded, Magnet2Finished], + Fullsync = true + }) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 2, + Magnets = [Magnet1Finished], + Fullsync = false + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + + // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // so when the second call `_cache.Add`s, it also affects `firstResult`. + // `.ToList()` clones it so it won't be changed by the second call + var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); + var secondResult = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.Equal(2, firstResult.Count); + Assert.Contains(firstResult, t => t.Id == Magnet1HalfDownloaded.Id.ToString() && t.Status == Magnet1HalfDownloaded.Status); + Assert.Contains(firstResult, t => t.Id == Magnet2Finished.Id.ToString()); + Assert.Equal(2, secondResult.Count); + Assert.Contains(secondResult, t => t.Id == Magnet1Finished.Id.ToString() && t.Status == Magnet1Finished.Status); + Assert.Contains(secondResult, t => t.Id == Magnet2Finished.Id.ToString()); + } + + public static IEnumerable DownloadingMagnetsWithProgress() + { + return [[Magnet1HalfDownloaded, 50], [Magnet2QuarterDownloaded, 25]]; + } + + [Theory] + [MemberData(nameof(DownloadingMagnetsWithProgress))] + public async Task Map_WhenTorrentDownloading_ComputesProgress(Magnet magnet, Int64 expectedProgress) + { + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [magnet], + Fullsync = true + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + + // We have to use `GetTorrents` since `Map` is private + var result = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.Equal(expectedProgress, result.First().Progress); + } + + [Fact] + public async Task UpdateData_WhenTorrentRdIdNull_ReturnsUnmodifiedTorrent() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = null, + Hash = "hash", + RdName = "rdName" + }; + + var serializedOriginal = JsonConvert.SerializeObject(torrent); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.UpdateData(torrent, null); + + // Assert + Assert.Equal(serializedOriginal, JsonConvert.SerializeObject(result)); + mocks.AllDebridClientFactoryMock.Verify(f => f.GetClient(), Times.Never); + } + + [Fact] + public async Task UpdateData_WhenTorrentClientTorrentNotProvided_FetchesFromAPIAndUpdatesTorrentAccordingly() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = Magnet1Finished.Id.ToString(), + Hash = Magnet1Finished.Hash!, + RdName = null, + RdSize = null, + RdStatus = null + }; + + mocks.AllDebridClientMock.Setup(c => c.Magnet.StatusAsync(torrent.RdId, It.IsAny())).ReturnsAsync(Magnet1Finished); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.UpdateData(torrent, null); + + // Assert + mocks.AllDebridClientMock.Verify(c => c.Magnet.StatusAsync(torrent.RdId, It.IsAny()), Times.Once); + Assert.Equal(Magnet1Finished.Filename, result.RdName); + Assert.Equal(Magnet1Finished.Size, result.RdSize); + Assert.Equal(Provider.AllDebrid, result.ClientKind); + Assert.Equal(TorrentStatus.Finished, result.RdStatus); + + // It mutates the original object: + Assert.Equal(torrent, result); + } + + [Fact] + public async Task UpdateData_WhenTorrentClientTorrentNotProvidedAndTorrentDeletedFromAD_UpdatesRdStatusRaw() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "123456", + Hash = "fake-hash-123456", + RdStatus = null, + RdStatusRaw = null + }; + + mocks.AllDebridClientMock.Setup(c => c.Magnet.StatusAsync(torrent.RdId, It.IsAny())) + .ThrowsAsync(new AllDebridException("Magnet not found", "MAGNET_INVALID_ID")); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.UpdateData(torrent, null); + + // Assert + Assert.Equal("deleted", result.RdStatusRaw); + Assert.Null(result.RdStatus); + + // It mutates the original object: + Assert.Equal(torrent, result); + } + + [Fact] + public async Task UpdateData_WhenTorrentClientTorrentIsProvided_DoesNotFetchFromApiAndUpdatesTorrentAccordingly() + { + // Double check fakes are as we expect + Assert.Equal(4, Magnet1Finished.StatusCode); + + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = Magnet1Finished.Id.ToString() + }; + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [Magnet2Finished], + Fullsync = true + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + var torrentClientTorrent = (await allDebridTorrentClient.GetTorrents()).First(); + + // Act + var result = await allDebridTorrentClient.UpdateData(torrent, torrentClientTorrent); + + // Assert + Assert.Equal(torrentClientTorrent.Filename, torrent.RdName); + Assert.Equal(torrentClientTorrent.Bytes, result.RdSize); + Assert.Equal(TorrentStatus.Finished, torrent.RdStatus); + Assert.Equal(Provider.AllDebrid, torrent.ClientKind); + + // It mutates the original object: + Assert.Equal(torrent, result); + } + + [Fact] + public async Task GetDownloadLinks_WhenTorrentRdIdNull_ReturnsNull() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = null + }; + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.Null(result); + mocks.AllDebridClientFactoryMock.Verify(f => f.GetClient(), Times.Never); + } + + [Fact] + public async Task GetDownloadLinks_ByDefault_GetsLinksForAllFiles() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1" + }; + + List files = + [ + new() + { + FolderOrFileName = "file-1.txt", + Size = 50, + DownloadLink = "https://fake.url/file-1.txt" + }, + new() + { + FolderOrFileName = "file-2.txt", + Size = 150, + DownloadLink = "https://fake.url/file-2.txt" + } + ]; + + var expectedLinksSet = new HashSet(files.Select(n => n.DownloadLink)!); + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(files); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenDownloadMinSizeSet_GetsLinksForOnlyFilesAboveThatSize() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + + // NB: this is in MB, the file sizes below are in B + DownloadMinSize = 100 + }; + + List files = + [ + new() + { + FolderOrFileName = "too-small.txt", + Size = (torrent.DownloadMinSize - 1) * 1024 * 1024, + DownloadLink = "https://fake.url/too-small.txt" + }, + new() + { + FolderOrFileName = "bigger-than-min.txt", + Size = (torrent.DownloadMinSize + 1) * 1024 * 1024, + DownloadLink = "https://fake.url/bigger-than-min.txt" + } + ]; + + var expectedLinksSet = new HashSet(files.Where(m => m.Size > torrent.DownloadMinSize * 1024 * 1024).Select(n => n.DownloadLink)!); + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(files); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenIncludeRegexSet_GetsLinksForOnlyFilesMatchingRegex() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + IncludeRegex = "include" + }; + + var expectedLinksSet = new HashSet([ + "https://fake.url/include.txt", "https://fake.url/include-folder-exclude-file.txt", "https://fake.url/include-folder-include-file.txt", + "https://fake.url/exclude-folder-include-file.txt" + ]); + + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(IncludeExcludeFiles); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenExcludeRegexSet_GetsLinksForOnlyFilesNotMatchingRegex() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + ExcludeRegex = "exclude" + }; + + var expectedLinksSet = new HashSet([ + "https://fake.url/include.txt", "https://fake.url/include-folder-include-file.txt" + ]); + + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(IncludeExcludeFiles); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenIncludeAndExcludeRegexSet_GetsLinksForOnlyFilesMatchingIncludeRegexEvenIfNotMatchingExcludeRegex() + { + // Arrange + var mocks = new Mocks(); + + var torrentInclude = new Torrent + { + RdId = "1", + IncludeRegex = "include" + }; + + var torrentIncludeExclude = new Torrent + { + RdId = "2", + IncludeRegex = "include", + ExcludeRegex = "exclude" + }; + + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(IncludeExcludeFiles); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var includeOnlyResult = await allDebridTorrentClient.GetDownloadLinks(torrentInclude); + var includeExcludeResult = await allDebridTorrentClient.GetDownloadLinks(torrentIncludeExclude); + + // Assert + Assert.NotNull(includeOnlyResult); + Assert.NotNull(includeExcludeResult); + var includeOnlyLinksSet = new HashSet(includeOnlyResult); + var includeExcludeLinksSet = new HashSet(includeExcludeResult); + Assert.Equal(includeOnlyLinksSet, includeExcludeLinksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenAllFilesExcluded_ReturnsAllFiles() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + + // NB: this is in MB, the file sizes below are in B + DownloadMinSize = 100 + }; + + List files = + [ + new() + { + FolderOrFileName = "too-small-1.txt", + Size = (torrent.DownloadMinSize - 1) * 1024 * 1024, + DownloadLink = "https://fake.url/too-small-1.txt" + }, + new() + { + FolderOrFileName = "too-small-2.txt", + Size = (torrent.DownloadMinSize - 1) * 1024 * 1024, + DownloadLink = "https://fake.url/too-small-2.txt" + } + ]; + + var expectedLinksSet = new HashSet(files.Select(n => n.DownloadLink)!); + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(files); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + private class Mocks + { + public readonly Mock AllDebridClientFactoryMock; + public readonly Mock AllDebridClientMock; + public readonly Mock> LoggerMock; + + public Mocks() + { + LoggerMock = new Mock>(); + AllDebridClientMock = new Mock(); + AllDebridClientFactoryMock = new Mock(); + AllDebridClientFactoryMock.Setup(f => f.GetClient()).Returns(AllDebridClientMock.Object); + } + } +}