Merge pull request #691 from Cucumberrbob/feat/unit-tests
feat: Add unit tests
This commit is contained in:
commit
ee62bc3098
22 changed files with 1748 additions and 46 deletions
28
server/RdtClient.Data/Data/ITorrentData.cs
Normal file
28
server/RdtClient.Data/Data/ITorrentData.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public static class DiConfig
|
|||
|
||||
services.AddScoped<DownloadData>();
|
||||
services.AddScoped<SettingData>();
|
||||
services.AddScoped<TorrentData>();
|
||||
services.AddScoped<ITorrentData, TorrentData>();
|
||||
services.AddScoped<UserData>();
|
||||
}
|
||||
}
|
||||
269
server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs
Normal file
269
server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
server/RdtClient.Service.Test/RdtClient.Service.Test.csproj
Normal file
31
server/RdtClient.Service.Test/RdtClient.Service.Test.csproj
Normal 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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
308
server/RdtClient.Service.Test/Services/TorrentsTest.cs
Normal file
308
server/RdtClient.Service.Test/Services/TorrentsTest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
6
server/RdtClient.Service/Helpers/IDelayProvider.cs
Normal file
6
server/RdtClient.Service/Helpers/IDelayProvider.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace RdtClient.Service.Helpers;
|
||||
|
||||
public interface IDelayProvider
|
||||
{
|
||||
public Task Delay(Int32 milliseconds);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
24
server/RdtClient.Service/Services/IDownloads.cs
Normal file
24
server/RdtClient.Service/Services/IDownloads.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
16
server/RdtClient.Service/Wrappers/IProcess.cs
Normal file
16
server/RdtClient.Service/Wrappers/IProcess.cs
Normal 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();
|
||||
}
|
||||
6
server/RdtClient.Service/Wrappers/IProcessFactory.cs
Normal file
6
server/RdtClient.Service/Wrappers/IProcessFactory.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace RdtClient.Service.Wrappers;
|
||||
|
||||
public interface IProcessFactory
|
||||
{
|
||||
public IProcess NewProcess();
|
||||
}
|
||||
44
server/RdtClient.Service/Wrappers/Process.cs
Normal file
44
server/RdtClient.Service/Wrappers/Process.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
9
server/RdtClient.Service/Wrappers/ProcessFactory.cs
Normal file
9
server/RdtClient.Service/Wrappers/ProcessFactory.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace RdtClient.Service.Wrappers;
|
||||
|
||||
public class ProcessFactory: IProcessFactory
|
||||
{
|
||||
public IProcess NewProcess()
|
||||
{
|
||||
return new Process();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue