Merge pull request #691 from Cucumberrbob/feat/unit-tests

feat: Add unit tests
This commit is contained in:
Roger Far 2025-02-23 11:22:20 -07:00 committed by GitHub
commit ee62bc3098
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1748 additions and 46 deletions

View file

@ -0,0 +1,28 @@
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
namespace RdtClient.Data.Data;
public interface ITorrentData
{
Task<IList<Torrent>> Get();
Task<Torrent?> GetById(Guid torrentId);
Task<Torrent?> GetByHash(String hash);
Task<Torrent> Add(String rdId,
String hash,
String? fileOrMagnetContents,
Boolean isFile,
DownloadClient downloadClient,
Torrent torrent);
Task UpdateRdData(Torrent torrent);
Task Update(Torrent torrent);
Task UpdateCategory(Guid torrentId, String? category);
Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry);
Task UpdateFilesSelected(Guid torrentId, DateTimeOffset datetime);
Task UpdatePriority(Guid torrentId, Int32? priority);
Task UpdateRetry(Guid torrentId, DateTimeOffset? dateTime, Int32 retryCount);
Task UpdateError(Guid torrentId, String error);
Task Delete(Guid torrentId);
}

View file

@ -4,7 +4,7 @@ using RdtClient.Data.Models.Data;
namespace RdtClient.Data.Data;
public class TorrentData(DataContext dataContext)
public class TorrentData(DataContext dataContext) : ITorrentData
{
private static IList<Torrent>? _torrentCache;

View file

@ -19,7 +19,7 @@ public static class DiConfig
services.AddScoped<DownloadData>();
services.AddScoped<SettingData>();
services.AddScoped<TorrentData>();
services.AddScoped<ITorrentData, TorrentData>();
services.AddScoped<UserData>();
}
}

View file

@ -0,0 +1,269 @@
using System.IO.Abstractions.TestingHelpers;
using System.Text.Json;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.TorrentClient;
using RdtClient.Service.Helpers;
namespace RdtClient.Service.Test.Helpers;
public class DownloadHelperTest
{
[Fact]
public void GetDownloadPath_WithPath_WhenRdNameNull_ReturnsNull()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var torrent = new Torrent
{
RdName = null
};
// Act
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download);
// Assert
Assert.Null(path);
}
[Fact]
public void GetDownloadPath_WithoutPath_WhenRdNameNull_ReturnsNull()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var torrent = new Torrent
{
RdName = null
};
// Act
var path = DownloadHelper.GetDownloadPath(torrent, download);
// Assert
Assert.Null(path);
}
[Fact]
public void GetDownloadPath_WithPath_WhenDownloadLinkNull_ReturnsNull()
{
// Arrange
var download = new Download
{
Link = null,
FileName = "file.txt"
};
var torrent = new Torrent
{
RdName = "Torrent Name"
};
// Act
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download);
// Assert
Assert.Null(path);
}
[Fact]
public void GetDownloadPath_WithoutPath_WhenDownloadLinkNull_ReturnsNull()
{
// Arrange
var download = new Download
{
Link = null,
FileName = "file.txt"
};
var torrent = new Torrent
{
RdName = "Torrent Name"
};
// Act
var path = DownloadHelper.GetDownloadPath(torrent, download);
// Assert
Assert.Null(path);
}
[Fact]
public void GetDownloadPath_WithPath_WhenDownloadFileNameNull_UsesLinkToGuessFileName()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/filename-from-link.txt",
FileName = null
};
var torrent = new Torrent
{
RdName = "Torrent Name"
};
var fileSystem = new MockFileSystem();
// Act
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem);
// Assert
var expectedPath = Path.Combine("/data/downloads", torrent.RdName, "filename-from-link.txt");
Assert.Equal(expectedPath, path);
}
[Fact]
public void GetDownloadPath_WithoutPath_WhenDownloadFileNameNull_UsesLinkToGuessFileName()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/filename-from-link.txt",
FileName = null
};
var torrent = new Torrent
{
RdName = "Torrent Name"
};
// Act
var path = DownloadHelper.GetDownloadPath(torrent, download);
// Assert
var expectedPath = Path.Combine(torrent.RdName, "filename-from-link.txt");
Assert.Equal(expectedPath, path);
}
[Fact]
public void GetDownloadPath_WithPath_WhenValid_CreatesDirectory()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var torrent = new Torrent
{
RdName = "Torrent Name"
};
var fileSystem = new MockFileSystem();
// Act
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem);
// Assert
var expectedDirectoryPath = Path.Combine("/data/downloads", torrent.RdName);
Assert.True(fileSystem.Directory.Exists(expectedDirectoryPath));
var expectedPath = Path.Combine(expectedDirectoryPath, download.FileName);
Assert.Equal(expectedPath, path);
}
[Fact]
public void GetDownloadPath_WithPath_WhenFileInSubdirectories_ReturnsPathWithSubdirectories()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var fileRelativePath = "inside/lots/of/subdirectories/file.txt";
IList<TorrentClientFile> files =
[
new()
{
Path = fileRelativePath
}
];
var torrent = new Torrent
{
RdName = "Torrent Name",
RdFiles = JsonSerializer.Serialize(files)
};
var fileSystem = new MockFileSystem();
// Act
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem);
// Assert
var expectedPath = Path.Combine("/data/downloads", torrent.RdName, fileRelativePath);
Assert.Equal(expectedPath, path);
}
[Fact]
public void GetDownloadPath_WithoutPath_WhenFileInSubdirectories_ReturnsPathWithSubdirectories()
{
// Arrange
var download = new Download
{
Link = "https://fake.url/file.txt",
FileName = "file.txt"
};
var fileRelativePath = "inside/lots/of/subdirectories/file.txt";
IList<TorrentClientFile> files =
[
new()
{
Path = fileRelativePath
}
];
var torrent = new Torrent
{
RdName = "Torrent Name",
RdFiles = JsonSerializer.Serialize(files)
};
// Act
var path = DownloadHelper.GetDownloadPath(torrent, download);
// Assert
var expectedPath = Path.Combine(torrent.RdName, fileRelativePath);
Assert.Equal(expectedPath, path);
}
// This is probably a bug
[Fact]
public void GetDownloadPath_WithPath_WhenNoUriSegmentsOrFileName_ReturnsTorrentDirectory()
{
// Arrange
var download = new Download
{
Link = "https://fake.url", // HttpUtility.UrlDecode(new Uri("https://fake.url").Segments.Last()) == "/"
FileName = null
};
var torrent = new Torrent
{
RdName = "Torrent Name"
};
var fileSystem = new MockFileSystem();
// Act
var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem);
// Assert
var expectedPath = Path.Combine("/data/downloads", torrent.RdName);
Assert.Equal(expectedPath, path);
}
}

View file

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<AssemblyName>RdtClient.Service.Test</AssemblyName>
<RootNamespace>RdtClient.Service.Test</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AllDebrid.NET" Version="1.0.17" />
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1"/>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="21.3.1" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="21.3.1" />
<PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RdtClient.Service\RdtClient.Service.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,213 @@
using Moq;
using RdtClient.Service.Helpers;
using RdtClient.Service.Services.Downloaders;
using Synology.Api.Client;
using Synology.Api.Client.Apis.DownloadStation;
using Synology.Api.Client.Apis.DownloadStation.Task.Models;
namespace RdtClient.Service.Test.Services.Downloaders;
class Mocks
{
public readonly String Gid;
public readonly Mock<ISynologyClient> SynologyClientMock = new();
public readonly Mock<IDownloadStationTaskEndpoint> TaskEndpointMock = new();
public Mocks(String gid = "123456")
{
Gid = gid;
var downloadStationApiMock = new Mock<IDownloadStationApi>();
downloadStationApiMock.Setup(a => a.TaskEndpoint()).Returns(TaskEndpointMock.Object);
SynologyClientMock.Setup(c => c.DownloadStationApi()).Returns(downloadStationApiMock.Object);
}
}
class FakeDelayProvider : IDelayProvider
{
public Task Delay(Int32 milliseconds)
{
return Task.CompletedTask;
}
}
public class DownloadStationDownloaderTest
{
[Fact]
public async Task Download_WhenRemotePathEmpty_Throws()
{
// Arrange
var synologyClientMock = new Mock<ISynologyClient>();
var gid = Guid.NewGuid();
var downloadStationDownloader = new DownloadStationDownloader(gid.ToString(),
"https://fake.url/file.txt",
"",
"/path/to/file.txt",
"download-path",
synologyClientMock.Object);
// Act
var exception = await Assert.ThrowsAsync<Exception>(downloadStationDownloader.Download);
// Assert
Assert.Contains("invalid file path", exception.Message, StringComparison.OrdinalIgnoreCase);
synologyClientMock.VerifyNoOtherCalls();
}
[Fact]
public async Task Download_WhenAlreadyAdded_Throws()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ReturnsAsync(new DownloadStationTask());
var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid,
"https://fake.url/file.txt",
"/path/on/remote/file.txt",
"/path/to/file.txt",
"download-path",
mocks.SynologyClientMock.Object);
// Act
var exception = await Assert.ThrowsAsync<Exception>(downloadStationDownloader.Download);
// Assert
Assert.Contains("already been added", exception.Message, StringComparison.OrdinalIgnoreCase);
mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once);
mocks.TaskEndpointMock.VerifyNoOtherCalls();
}
[Fact]
public async Task Download_WhenAddedSuccessfully_ReturnsGid()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new Exception());
mocks.TaskEndpointMock.Setup(t => t.ListAsync())
.ReturnsAsync(new DownloadStationTaskListResponse
{
Total = 0,
Offset = 0
});
mocks.TaskEndpointMock.Setup(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()))
.ReturnsAsync(new DownloadStationTaskCreateResponse
{
TaskId = [mocks.Gid]
});
var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid,
"https://fake.url/file.txt",
"/path/on/remote/file.txt",
"/path/to/file.txt",
"download-path",
mocks.SynologyClientMock.Object);
// Act
var result = await downloadStationDownloader.Download();
// Assert
Assert.Equal(mocks.Gid, result);
mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once);
mocks.TaskEndpointMock.Verify(t => t.ListAsync(), Times.Once);
mocks.TaskEndpointMock.Verify(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()), Times.Once);
mocks.TaskEndpointMock.VerifyNoOtherCalls();
}
[Fact]
public async Task Download_After5Tries_Throws()
{
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new Exception());
var emptyListResponse = new DownloadStationTaskListResponse
{
Total = 0,
Offset = 0
};
mocks.TaskEndpointMock.SetupSequence(t => t.ListAsync())
.ReturnsAsync(emptyListResponse)
.ReturnsAsync(emptyListResponse)
.ReturnsAsync(emptyListResponse)
.ReturnsAsync(emptyListResponse)
.ReturnsAsync(emptyListResponse);
mocks.TaskEndpointMock.SetupSequence(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()))
.ThrowsAsync(new Exception())
.ThrowsAsync(new Exception())
.ThrowsAsync(new Exception())
.ThrowsAsync(new Exception())
.ThrowsAsync(new Exception());
var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid,
"https://fake.url/file.txt",
"/path/on/remote/file.txt",
"/path/to/file.txt",
"download-path",
mocks.SynologyClientMock.Object,
new FakeDelayProvider());
// Act
var exception = await Assert.ThrowsAsync<Exception>(downloadStationDownloader.Download);
// Assert
Assert.Contains("unable to download", exception.Message, StringComparison.OrdinalIgnoreCase);
mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once);
mocks.TaskEndpointMock.Verify(t => t.ListAsync(), Times.Exactly(5));
mocks.TaskEndpointMock.Verify(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()), Times.Exactly(5));
mocks.TaskEndpointMock.VerifyNoOtherCalls();
}
[Fact]
public async Task Download_WhenSuccessfulAfter4Tries_ReturnsGid()
{
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new Exception());
var emptyListResponse = new DownloadStationTaskListResponse
{
Total = 0,
Offset = 0
};
mocks.TaskEndpointMock.SetupSequence(t => t.ListAsync())
.ReturnsAsync(emptyListResponse)
.ReturnsAsync(emptyListResponse)
.ReturnsAsync(emptyListResponse)
.ReturnsAsync(emptyListResponse)
.ReturnsAsync(emptyListResponse);
mocks.TaskEndpointMock.SetupSequence(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()))
.ThrowsAsync(new Exception())
.ThrowsAsync(new Exception())
.ThrowsAsync(new Exception())
.ThrowsAsync(new Exception())
.ReturnsAsync(new DownloadStationTaskCreateResponse
{
TaskId = [mocks.Gid]
});
var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid,
"https://fake.url/file.txt",
"/path/on/remote/file.txt",
"/path/to/file.txt",
"download-path",
mocks.SynologyClientMock.Object,
new FakeDelayProvider());
// Act
var result = await downloadStationDownloader.Download();
// Assert
Assert.Equal(mocks.Gid, result);
mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once);
mocks.TaskEndpointMock.Verify(t => t.ListAsync(), Times.Exactly(5));
mocks.TaskEndpointMock.Verify(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()), Times.Exactly(5));
mocks.TaskEndpointMock.VerifyNoOtherCalls();
}
}

View file

@ -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<File> 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<Int64>(), It.IsAny<Int64>(), It.IsAny<CancellationToken>()))
.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<Int64>(), It.IsAny<Int64>(), It.IsAny<CancellationToken>()))
.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<Int64>(), It.IsAny<Int64>(), It.IsAny<CancellationToken>()))
.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<Int64>(), It.IsAny<Int64>(), It.IsAny<CancellationToken>()))
.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<Int64>(), It.IsAny<Int64>(), It.IsAny<CancellationToken>()))
.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<Object[]> 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<Int64>(), It.IsAny<Int64>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>())).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<CancellationToken>()), 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<CancellationToken>()))
.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<Int64>(), It.IsAny<Int64>(), It.IsAny<CancellationToken>()))
.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<File> 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<String>(files.Select(n => n.DownloadLink)!);
mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny<CancellationToken>())).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<String>(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<File> 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<String>(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<CancellationToken>())).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<String>(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<String>([
"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<CancellationToken>())).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<String>(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<String>([
"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<CancellationToken>())).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<String>(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<Int64>(), It.IsAny<CancellationToken>())).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<String>(includeOnlyResult);
var includeExcludeLinksSet = new HashSet<String>(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<File> 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<String>(files.Select(n => n.DownloadLink)!);
mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny<CancellationToken>())).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<String>(result);
Assert.Equal(expectedLinksSet, linksSet);
}
private class Mocks
{
public readonly Mock<IAllDebridNetClientFactory> AllDebridClientFactoryMock;
public readonly Mock<IAllDebridNETClient> AllDebridClientMock;
public readonly Mock<ILogger<AllDebridTorrentClient>> LoggerMock;
public Mocks()
{
LoggerMock = new Mock<ILogger<AllDebridTorrentClient>>();
AllDebridClientMock = new Mock<IAllDebridNETClient>();
AllDebridClientFactoryMock = new Mock<IAllDebridNetClientFactory>();
AllDebridClientFactoryMock.Setup(f => f.GetClient()).Returns(AllDebridClientMock.Object);
}
}
}

View file

@ -0,0 +1,308 @@
using System.Diagnostics;
using System.IO.Abstractions.TestingHelpers;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Data.Data;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services;
using RdtClient.Service.Wrappers;
using TorrentsService = RdtClient.Service.Services.Torrents;
namespace RdtClient.Service.Test.Services;
class Mocks
{
public readonly Mock<IProcessFactory> ProcessFactoryMock;
public readonly Mock<IProcess> ProcessMock;
public readonly Mock<ILogger<TorrentsService>> TorrentsLoggerMock;
public readonly Mock<IDownloads> DownloadsMock;
public readonly Mock<ITorrentData> TorrentDataMock;
public Mocks()
{
TorrentDataMock = new();
DownloadsMock = new();
TorrentsLoggerMock = new();
ProcessMock = new();
ProcessStartInfo startInfo = new();
ProcessMock.SetupProperty(p => p.StartInfo, startInfo);
ProcessFactoryMock = new();
ProcessFactoryMock.Setup(p => p.NewProcess()).Returns(ProcessMock.Object);
}
}
public class TorrentsTest
{
public static IEnumerable<Object[]> TorrentAndDownload()
{
var torrent = new Torrent()
{
RdName = "TestTorrent",
Hash = "123ABC",
Category = "Movies",
RdSize = 100,
TorrentId = new Guid()
};
List<Download> downloads =
[
new()
{
FileName = "file.txt",
TorrentId = torrent.TorrentId
}
];
yield return new Object[]
{
torrent, downloads
};
}
[Theory]
[MemberData(nameof(TorrentAndDownload))]
public async Task RunTorrentComplete_WhenCommandSet_ShouldRunCommand(Torrent torrent, List<Download> downloads)
{
// Arrange
var settings = new DbSettings
{
General = new DbSettingsGeneral()
{
RunOnTorrentCompleteFileName = "/bin/echo",
RunOnTorrentCompleteArguments = "%N %L %F %R %D %C %Z %I"
}
};
var mocks = new Mocks();
mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult<Torrent?>(torrent));
mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads);
var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}";
var torrentPath = $"{downloadPath}/{torrent.RdName}";
var filePath = $"{torrentPath}/{downloads[0].FileName}";
var fileSystemMock = new MockFileSystem(new Dictionary<String, MockFileData>
{
{
filePath, new MockFileData("Test file")
},
});
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
mocks.TorrentDataMock.Object,
mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object,
fileSystemMock,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!,
null!,
null!,
null!);
mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny<Int32>())).Returns(true);
// Act
await torrents.RunTorrentComplete(torrent.TorrentId, settings);
// Assert
Assert.Equal("/bin/echo", mocks.ProcessMock.Object.StartInfo.FileName);
var expectedArgumentsSb = new StringBuilder();
expectedArgumentsSb.Append($"\"{torrent.RdName}\"");
expectedArgumentsSb.Append($" \"{torrent.Category}\"");
expectedArgumentsSb.Append($" \"{filePath}\"");
expectedArgumentsSb.Append($" \"{downloadPath}\"");
expectedArgumentsSb.Append($" \"{torrentPath}\"");
expectedArgumentsSb.Append($" {downloads.Count.ToString()}");
expectedArgumentsSb.Append($" {torrent.RdSize.ToString()}");
expectedArgumentsSb.Append($" {torrent.Hash}");
Assert.Equal(expectedArgumentsSb.ToString(), mocks.ProcessMock.Object.StartInfo.Arguments);
mocks.ProcessMock.Verify(p => p.Start(), Times.Once);
}
[Theory]
[MemberData(nameof(TorrentAndDownload))]
public async Task RunTorrentComplete_WhenCommandNotSet_ShouldNotRunCommand(Torrent torrent, List<Download> downloads)
{
// Arrange
var settings = new DbSettings()
{
General = new DbSettingsGeneral()
{
RunOnTorrentCompleteFileName = null
}
};
var mocks = new Mocks();
mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult<Torrent?>(torrent));
mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads);
var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}";
var torrentPath = $"{downloadPath}/{torrent.RdName}";
var filePath = $"{torrentPath}/{downloads[0].FileName}";
var fileSystemMock = new MockFileSystem(new Dictionary<String, MockFileData>
{
{
filePath, new MockFileData("Test file")
},
});
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
mocks.TorrentDataMock.Object,
mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object,
fileSystemMock,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!,
null!,
null!,
null!);
//Act
await torrents.RunTorrentComplete(torrent.TorrentId, settings);
//Assert
mocks.ProcessFactoryMock.VerifyNoOtherCalls();
}
[Theory]
[MemberData(nameof(TorrentAndDownload))]
public async Task RunTorrentComplete_WhenStdOut_Logs(Torrent torrent, List<Download> downloads)
{
// Arrange
var settings = new DbSettings()
{
General = new DbSettingsGeneral()
{
RunOnTorrentCompleteFileName = "/bin/echo"
}
};
var mocks = new Mocks();
mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult<Torrent?>(torrent));
mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads);
var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}";
var torrentPath = $"{downloadPath}/{torrent.RdName}";
var filePath = $"{torrentPath}/{downloads[0].FileName}";
var fileSystemMock = new MockFileSystem(new Dictionary<String, MockFileData>
{
{
filePath, new MockFileData("Test file")
},
});
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
mocks.TorrentDataMock.Object,
mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object,
fileSystemMock,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!,
null!,
null!,
null!);
mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny<Int32>()))
.Callback(() =>
{
mocks.ProcessMock.Raise(m => m.OutputDataReceived += null, this, "output-line 1");
mocks.ProcessMock.Raise(m => m.OutputDataReceived += null, this, "output-line 2");
mocks.ProcessMock.Raise(m => m.OutputDataReceived += null, this, "output-line 3");
})
.Returns(true);
// Act
await torrents.RunTorrentComplete(torrent.TorrentId, settings);
// Assert
mocks.ProcessMock.Verify(p => p.BeginOutputReadLine(), Times.Once);
var messages = mocks.TorrentsLoggerMock.Invocations.Where(i => i.Method.Name == "Log").Select(i => i.Arguments[2].ToString()).Where(m => m != null).ToList();
var exitedWithOutputMessages = messages.Where(m => Regex.IsMatch(m!, "exited with output")).ToList();
Assert.NotNull(exitedWithOutputMessages);
Assert.Single(exitedWithOutputMessages);
var exitedWithOutputMessage = exitedWithOutputMessages.First();
Assert.NotNull(exitedWithOutputMessage);
Assert.Matches("output-line 1", exitedWithOutputMessage);
Assert.Matches("output-line 2", exitedWithOutputMessage);
Assert.Matches("output-line 3", exitedWithOutputMessage);
}
[Theory]
[MemberData(nameof(TorrentAndDownload))]
public async Task RunTorrentComplete_WhenStdErr_Logs(Torrent torrent, List<Download> downloads)
{
// Arrange
var settings = new DbSettings()
{
General = new DbSettingsGeneral()
{
RunOnTorrentCompleteFileName = "/bin/echo"
}
};
var mocks = new Mocks();
mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult<Torrent?>(torrent));
mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads);
var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}";
var torrentPath = $"{downloadPath}/{torrent.RdName}";
var filePath = $"{torrentPath}/{downloads[0].FileName}";
var fileSystemMock = new MockFileSystem(new Dictionary<String, MockFileData>
{
{
filePath, new MockFileData("Test file")
},
});
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
mocks.TorrentDataMock.Object,
mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object,
fileSystemMock,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!,
null!,
null!,
null!);
mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny<Int32>()))
.Callback(() =>
{
mocks.ProcessMock.Raise(m => m.ErrorDataReceived += null, this, "error-line 1");
mocks.ProcessMock.Raise(m => m.ErrorDataReceived += null, this, "error-line 2");
mocks.ProcessMock.Raise(m => m.ErrorDataReceived += null, this, "error-line 3");
})
.Returns(true);
// Act
await torrents.RunTorrentComplete(torrent.TorrentId, settings);
// Assert
mocks.ProcessMock.Verify(p => p.BeginErrorReadLine(), Times.Once);
var messages = mocks.TorrentsLoggerMock.Invocations.Where(i => i.Method.Name == "Log").Select(i => i.Arguments[2].ToString()).Where(m => m != null).ToList();
var exitedWithOutputMessages = messages.Where(m => Regex.IsMatch(m!, "exited with errors")).ToList();
Assert.NotNull(exitedWithOutputMessages);
Assert.Single(exitedWithOutputMessages);
var exitedWithOutputMessage = exitedWithOutputMessages.First();
Assert.NotNull(exitedWithOutputMessage);
Assert.Matches("error-line 1", exitedWithOutputMessage);
Assert.Matches("error-line 2", exitedWithOutputMessage);
Assert.Matches("error-line 3", exitedWithOutputMessage);
}
}

View file

@ -1,4 +1,5 @@
using System.Net;
using System.IO.Abstractions;
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Polly;
@ -7,17 +8,24 @@ using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Middleware;
using RdtClient.Service.Services;
using RdtClient.Service.Services.TorrentClients;
using RdtClient.Service.Wrappers;
namespace RdtClient.Service;
public static class DiConfig
{
public const String RD_CLIENT = "RdClient";
public static void RegisterRdtServices(this IServiceCollection services)
{
services.AddSingleton<IAllDebridNetClientFactory, AllDebridNetClientFactory>();
services.AddScoped<AllDebridTorrentClient>();
services.AddSingleton<IProcessFactory, ProcessFactory>();
services.AddSingleton<IFileSystem, FileSystem>();
services.AddScoped<Authentication>();
services.AddScoped<IDownloads, Downloads>();
services.AddScoped<Downloads>();
services.AddScoped<PremiumizeTorrentClient>();
services.AddScoped<QBittorrent>();
@ -30,7 +38,7 @@ public static class DiConfig
services.AddScoped<DebridLinkClient>();
services.AddSingleton<IAuthorizationHandler, AuthSettingHandler>();
services.AddHostedService<ProviderUpdater>();
services.AddHostedService<Startup>();
services.AddHostedService<TaskRunner>();
@ -50,4 +58,4 @@ public static class DiConfig
services.AddHttpClient(RD_CLIENT)
.AddPolicyHandler(retryPolicy);
}
}
}

View file

@ -1,11 +1,12 @@
using RdtClient.Data.Models.Data;
using System.IO.Abstractions;
using RdtClient.Data.Models.Data;
using System.Web;
namespace RdtClient.Service.Helpers;
public static class DownloadHelper
{
public static String? GetDownloadPath(String downloadPath, Torrent torrent, Download download)
public static String? GetDownloadPath(String downloadPath, Torrent torrent, Download download, IFileSystem? fileSystem = null)
{
var fileUrl = download.Link;
@ -41,9 +42,11 @@ public static class DownloadHelper
}
}
if (!Directory.Exists(torrentPath))
fileSystem ??= new FileSystem();
if (!fileSystem.Directory.Exists(torrentPath))
{
Directory.CreateDirectory(torrentPath);
fileSystem.Directory.CreateDirectory(torrentPath);
}
var filePath = Path.Combine(torrentPath, fileName);

View file

@ -0,0 +1,6 @@
namespace RdtClient.Service.Helpers;
public interface IDelayProvider
{
public Task Delay(Int32 milliseconds);
}

View file

@ -24,6 +24,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="Synology.Api.Client" Version="0.3.87" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="21.3.1" />
<PackageReference Include="TorBox.NET" Version="1.4.0" />
</ItemGroup>

View file

@ -1,9 +1,18 @@
using Serilog;
using RdtClient.Service.Helpers;
using Serilog;
using Synology.Api.Client;
using Synology.Api.Client.Apis.DownloadStation.Task.Models;
namespace RdtClient.Service.Services.Downloaders;
class DelayProvider : IDelayProvider
{
public Task Delay(Int32 delay)
{
return Task.Delay(delay);
}
}
public class DownloadStationDownloader : IDownloader
{
public event EventHandler<DownloadCompleteEventArgs>? DownloadComplete;
@ -11,16 +20,17 @@ public class DownloadStationDownloader : IDownloader
private const Int32 RetryCount = 5;
private readonly SynologyClient _synologyClient;
private readonly ISynologyClient _synologyClient;
private readonly ILogger _logger;
private readonly String _filePath;
private readonly String _uri;
private readonly String? _remotePath;
private readonly IDelayProvider _delayProvider;
private String? _gid;
private DownloadStationDownloader(String? gid, String uri, String? remotePath, String filePath, String downloadPath, SynologyClient synologyClient)
public DownloadStationDownloader(String? gid, String uri, String? remotePath, String filePath, String downloadPath, ISynologyClient synologyClient, IDelayProvider? delayProvider = null)
{
_logger = Log.ForContext<DownloadStationDownloader>();
_logger.Debug($"Instantiated new DownloadStation Downloader for URI {uri} to filePath {filePath} and downloadPath {downloadPath} and GID {gid}");
@ -30,6 +40,7 @@ public class DownloadStationDownloader : IDownloader
_uri = uri;
_remotePath = remotePath;
_synologyClient = synologyClient;
_delayProvider = delayProvider ?? new DelayProvider();
}
public static async Task<DownloadStationDownloader> Init(String? gid, String uri, String filePath, String downloadPath, String? category)
@ -149,13 +160,13 @@ public class DownloadStationDownloader : IDownloader
retryCount++;
_logger.Error($"Task not found in DownloadStation after creat Sucess. Retrying {retryCount}/{RetryCount}");
await Task.Delay(retryCount * 1000);
await _delayProvider.Delay(retryCount * 1000);
}
catch (Exception e)
{
retryCount++;
_logger.Error($"Error starting download: {e.Message}. Retrying {retryCount}/{RetryCount}");
await Task.Delay(retryCount * 1000);
await _delayProvider.Delay(retryCount * 1000);
}
}

View file

@ -3,7 +3,7 @@ using Download = RdtClient.Data.Models.Data.Download;
namespace RdtClient.Service.Services;
public class Downloads(DownloadData downloadData)
public class Downloads(DownloadData downloadData) : IDownloads
{
public async Task<List<Download>> GetForTorrent(Guid torrentId)
{

View file

@ -0,0 +1,24 @@
using RdtClient.Data.Models.Data;
namespace RdtClient.Service.Services;
public interface IDownloads
{
Task<List<Download>> GetForTorrent(Guid torrentId);
Task<Download?> GetById(Guid downloadId);
Task<Download?> Get(Guid torrentId, String path);
Task<Download> Add(Guid torrentId, String path);
Task UpdateUnrestrictedLink(Guid downloadId, String unrestrictedLink);
Task UpdateFileName(Guid downloadId, String fileName);
Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime);
Task UpdateDownloadFinished(Guid downloadId, DateTimeOffset? dateTime);
Task UpdateUnpackingQueued(Guid downloadId, DateTimeOffset? dateTime);
Task UpdateUnpackingStarted(Guid downloadId, DateTimeOffset? dateTime);
Task UpdateUnpackingFinished(Guid downloadId, DateTimeOffset? dateTime);
Task UpdateCompleted(Guid downloadId, DateTimeOffset? dateTime);
Task UpdateError(Guid downloadId, String? error);
Task UpdateRetryCount(Guid downloadId, Int32 retryCount);
Task UpdateRemoteId(Guid downloadId, String remoteId);
Task DeleteForTorrent(Guid torrentId);
Task Reset(Guid downloadId);
}

View file

@ -12,13 +12,14 @@ using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services.TorrentClients;
public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHttpClientFactory httpClientFactory) : ITorrentClient
public interface IAllDebridNetClientFactory
{
private static readonly Int64 SessionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
private static List<TorrentClientTorrent> _cache = [];
private static Int64 _sessionCounter = 0;
public IAllDebridNETClient GetClient();
}
private AllDebridNETClient GetClient()
public class AllDebridNetClientFactory(ILogger<AllDebridNetClientFactory> logger, IHttpClientFactory httpClientFactory) : IAllDebridNetClientFactory
{
public IAllDebridNETClient GetClient()
{
try
{
@ -49,6 +50,13 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
throw;
}
}
}
public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IAllDebridNetClientFactory allDebridNetClientFactory) : ITorrentClient
{
private static readonly Int64 SessionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
private static List<TorrentClientTorrent> _cache = [];
private static Int64 _sessionCounter = 0;
private static TorrentClientTorrent Map(Magnet torrent)
{
@ -77,7 +85,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
public async Task<IList<TorrentClientTorrent>> GetTorrents()
{
var results = await GetClient().Magnet.StatusLiveAsync(SessionId, _sessionCounter);
var results = await allDebridNetClientFactory.GetClient().Magnet.StatusLiveAsync(SessionId, _sessionCounter);
_sessionCounter = results.Counter;
@ -106,7 +114,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
public async Task<TorrentClientUser> GetUser()
{
var user = await GetClient().User.GetAsync() ?? throw new("Unable to get user");
var user = await allDebridNetClientFactory.GetClient().User.GetAsync() ?? throw new("Unable to get user");
return new()
{
@ -117,7 +125,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
public async Task<String> AddMagnet(String magnetLink)
{
var result = await GetClient().Magnet.UploadMagnetAsync(magnetLink);
var result = await allDebridNetClientFactory.GetClient().Magnet.UploadMagnetAsync(magnetLink);
if (result?.Id == null)
{
@ -131,7 +139,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
public async Task<String> AddFile(Byte[] bytes)
{
var result = await GetClient().Magnet.UploadFileAsync(bytes);
var result = await allDebridNetClientFactory.GetClient().Magnet.UploadFileAsync(bytes);
if (result?.Id == null)
{
@ -155,12 +163,12 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
public async Task Delete(String torrentId)
{
await GetClient().Magnet.DeleteAsync(torrentId);
await allDebridNetClientFactory.GetClient().Magnet.DeleteAsync(torrentId);
}
public async Task<String> Unrestrict(String link)
{
var result = await GetClient().Links.DownloadLinkAsync(link);
var result = await allDebridNetClientFactory.GetClient().Links.DownloadLinkAsync(link);
if (result.Link == null)
{
@ -245,7 +253,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
return null;
}
var allFiles = await GetClient().Magnet.FilesAsync(Int64.Parse(torrent.RdId));
var allFiles = await allDebridNetClientFactory.GetClient().Magnet.FilesAsync(Int64.Parse(torrent.RdId));
var files = GetFiles(allFiles);
@ -323,7 +331,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
{
Log($"{file.Path} ({file.Bytes}b) {file.DownloadLink}");
}
return files.Where(m => m.DownloadLink != null).Select(m => m.DownloadLink!.ToString()).ToList();
}
@ -341,7 +349,7 @@ public class AllDebridTorrentClient(ILogger<AllDebridTorrentClient> logger, IHtt
private async Task<TorrentClientTorrent> GetInfo(String torrentId)
{
var result = await GetClient().Magnet.StatusAsync(torrentId) ?? throw new($"Unable to find magnet with ID {torrentId}");
var result = await allDebridNetClientFactory.GetClient().Magnet.StatusAsync(torrentId) ?? throw new($"Unable to find magnet with ID {torrentId}");
return Map(result);
}

View file

@ -1,25 +1,29 @@
using System.Diagnostics;
using System.Globalization;
using System.Globalization;
using System.IO.Abstractions;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using MonoTorrent;
using System.Text.Json.Serialization;
using RdtClient.Data.Data;
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Data.Models.TorrentClient;
using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Helpers;
using RdtClient.Service.Services.TorrentClients;
using RdtClient.Service.Wrappers;
using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services;
public class Torrents(
ILogger<Torrents> logger,
TorrentData torrentData,
Downloads downloads,
ITorrentData torrentData,
IDownloads downloads,
IProcessFactory processFactory,
IFileSystem fileSystem,
AllDebridTorrentClient allDebridTorrentClient,
PremiumizeTorrentClient premiumizeTorrentClient,
RealDebridTorrentClient realDebridTorrentClient,
@ -691,9 +695,11 @@ public class Torrents(
await torrentData.Update(torrent);
}
public async Task RunTorrentComplete(Guid torrentId)
public async Task RunTorrentComplete(Guid torrentId, DbSettings? settings = null)
{
if (String.IsNullOrWhiteSpace(Settings.Get.General.RunOnTorrentCompleteFileName))
settings ??= Settings.Get;
if (String.IsNullOrWhiteSpace(settings.General.RunOnTorrentCompleteFileName))
{
return;
}
@ -702,8 +708,8 @@ public class Torrents(
var downloadsForTorrent = await downloads.GetForTorrent(torrentId);
var fileName = Settings.Get.General.RunOnTorrentCompleteFileName;
var arguments = Settings.Get.General.RunOnTorrentCompleteArguments ?? "";
var fileName = settings.General.RunOnTorrentCompleteFileName;
var arguments = settings.General.RunOnTorrentCompleteArguments ?? "";
Log($"Parsing external program {fileName} with arguments {arguments}", torrent);
@ -712,7 +718,7 @@ public class Torrents(
var filePath = torrentPath;
var files = Directory.GetFiles(filePath);
var files = fileSystem.Directory.GetFiles(filePath);
if (files.Length == 1)
{
@ -733,7 +739,7 @@ public class Torrents(
var errorSb = new StringBuilder();
var outputSb = new StringBuilder();
using var process = new Process();
using var process = processFactory.NewProcess();
process.StartInfo.FileName = fileName;
process.StartInfo.Arguments = arguments;
@ -744,21 +750,21 @@ public class Torrents(
process.OutputDataReceived += (_, data) =>
{
if (data.Data == null)
if (data == null)
{
return;
}
outputSb.AppendLine(data.Data.Trim());
outputSb.AppendLine(data.Trim());
};
process.ErrorDataReceived += (_, data) =>
{
if (data.Data == null)
if (data == null)
{
return;
}
errorSb.AppendLine(data.Data.Trim());
errorSb.AppendLine(data.Trim());
};
process.Start();
@ -807,7 +813,7 @@ public class Torrents(
}
}
private void Log(String message, Data.Models.Data.Download? download, Torrent? torrent)
private void Log(String message, Download? download, Torrent? torrent)
{
if (download != null)
{

View file

@ -0,0 +1,16 @@
using System.Diagnostics;
namespace RdtClient.Service.Wrappers;
public interface IProcess : IDisposable
{
event EventHandler<String?>? OutputDataReceived;
event EventHandler<String?>? ErrorDataReceived;
public ProcessStartInfo StartInfo { get; set; }
void BeginOutputReadLine();
void BeginErrorReadLine();
Boolean WaitForExit(Int32 milliseconds);
void Start();
}

View file

@ -0,0 +1,6 @@
namespace RdtClient.Service.Wrappers;
public interface IProcessFactory
{
public IProcess NewProcess();
}

View file

@ -0,0 +1,44 @@
using System.Diagnostics;
namespace RdtClient.Service.Wrappers;
public class Process : IProcess
{
private readonly System.Diagnostics.Process _process = new();
public ProcessStartInfo StartInfo
{
get => _process.StartInfo;
set => _process.StartInfo = value;
}
public event EventHandler<String?>? OutputDataReceived;
public event EventHandler<String?>? ErrorDataReceived;
public void Dispose()
{
_process.Dispose();
}
public void BeginOutputReadLine()
{
_process.OutputDataReceived += (sender, args) => OutputDataReceived?.Invoke(sender, args.Data);
_process.BeginOutputReadLine();
}
public void BeginErrorReadLine()
{
_process.ErrorDataReceived += (sender, args) => ErrorDataReceived?.Invoke(sender, args.Data);
_process.BeginErrorReadLine();
}
public Boolean WaitForExit(Int32 milliseconds)
{
return _process.WaitForExit(milliseconds);
}
public void Start()
{
_process.Start();
}
}

View file

@ -0,0 +1,9 @@
namespace RdtClient.Service.Wrappers;
public class ProcessFactory: IProcessFactory
{
public IProcess NewProcess()
{
return new Process();
}
}

View file

@ -9,6 +9,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RdtClient.Service", "RdtCli
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RdtClient.Data", "RdtClient.Data\RdtClient.Data.csproj", "{92EF8817-AD73-4301-93BD-745D7D61DD74}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RdtClient.Service.Test", "RdtClient.Service.Test\RdtClient.Service.Test.csproj", "{4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -27,6 +29,10 @@ Global
{92EF8817-AD73-4301-93BD-745D7D61DD74}.Debug|Any CPU.Build.0 = Debug|Any CPU
{92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|Any CPU.ActiveCfg = Release|Any CPU
{92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|Any CPU.Build.0 = Release|Any CPU
{4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE