- Applied to TorBox, can be extended easily to other providers - Reads response headers commonplace for pre-emptive rate limit throttling and retry-after - When a rate limit is reached, displays a warning in the UI - Fix socket leak from direct allocation of HttpClient, replaced with factory which handles pooling and re-use of sockets. While HttpClient is Disposable, it doesn't gaurantee (and does not) directly release underlying sockets for queries at the time the client is disposed. These sockets will go into a TCP WAIT state often for a very long time. The expected pattern in C# is to always use the HttClientFactory which will correctly handle re-use of the OS sockets in suqsequent queries reducing resource and memory leaks.
411 lines
17 KiB
C#
411 lines
17 KiB
C#
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.Enums;
|
|
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<IDownloads> DownloadsMock;
|
|
public readonly Mock<IEnricher> EnricherMock;
|
|
public readonly Mock<IProcessFactory> ProcessFactoryMock;
|
|
public readonly Mock<IProcess> ProcessMock;
|
|
public readonly Mock<ITorrentData> TorrentDataMock;
|
|
public readonly Mock<ILogger<TorrentsService>> TorrentsLoggerMock;
|
|
|
|
public Mocks()
|
|
{
|
|
TorrentDataMock = new();
|
|
DownloadsMock = new();
|
|
EnricherMock = 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 TheoryData<Torrent, List<Download>> TorrentAndDownload()
|
|
{
|
|
var torrent = new Torrent
|
|
{
|
|
RdName = "TestTorrent",
|
|
Hash = "123ABC",
|
|
Category = "Movies",
|
|
RdSize = 100,
|
|
TorrentId = Guid.Empty
|
|
};
|
|
|
|
List<Download> downloads =
|
|
[
|
|
new()
|
|
{
|
|
FileName = "file.txt",
|
|
TorrentId = torrent.TorrentId
|
|
}
|
|
];
|
|
|
|
return new()
|
|
{
|
|
{
|
|
torrent, downloads
|
|
}
|
|
};
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(TorrentAndDownload))]
|
|
public async Task RunTorrentComplete_WhenCommandSet_ShouldRunCommand(Torrent torrent, List<Download> downloads)
|
|
{
|
|
// Arrange
|
|
var settings = new DbSettings
|
|
{
|
|
General = new()
|
|
{
|
|
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("Test file")
|
|
}
|
|
});
|
|
|
|
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
|
|
mocks.TorrentDataMock.Object,
|
|
mocks.DownloadsMock.Object,
|
|
mocks.ProcessFactoryMock.Object,
|
|
fileSystemMock,
|
|
mocks.EnricherMock.Object,
|
|
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}");
|
|
|
|
var expectedArguments = expectedArgumentsSb.ToString();
|
|
|
|
Assert.Equal(expectedArguments, 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()
|
|
{
|
|
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("Test file")
|
|
}
|
|
});
|
|
|
|
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
|
|
mocks.TorrentDataMock.Object,
|
|
mocks.DownloadsMock.Object,
|
|
mocks.ProcessFactoryMock.Object,
|
|
fileSystemMock,
|
|
mocks.EnricherMock.Object,
|
|
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()
|
|
{
|
|
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("Test file")
|
|
}
|
|
});
|
|
|
|
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
|
|
mocks.TorrentDataMock.Object,
|
|
mocks.DownloadsMock.Object,
|
|
mocks.ProcessFactoryMock.Object,
|
|
fileSystemMock,
|
|
mocks.EnricherMock.Object,
|
|
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()
|
|
{
|
|
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("Test file")
|
|
}
|
|
});
|
|
|
|
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
|
|
mocks.TorrentDataMock.Object,
|
|
mocks.DownloadsMock.Object,
|
|
mocks.ProcessFactoryMock.Object,
|
|
fileSystemMock,
|
|
mocks.EnricherMock.Object,
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddNzbFileToDebridQueue_ShouldSetDownloadTypeNzb()
|
|
{
|
|
// Arrange
|
|
var mocks = new Mocks();
|
|
var torrent = new Torrent
|
|
{
|
|
TorrentId = Guid.NewGuid()
|
|
};
|
|
var nzbContent = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\r\n<nzb xmlns=\"http://www.newzbin.com/DTD/2003/nzb\">\r\n <head>\r\n <meta type=\"title\">Test NZB Title</meta>\r\n </head>\r\n</nzb>";
|
|
var bytes = Encoding.UTF8.GetBytes(nzbContent);
|
|
|
|
mocks.TorrentDataMock.Setup(t => t.Add(It.IsAny<String>(),
|
|
It.IsAny<String>(),
|
|
It.IsAny<String>(),
|
|
true,
|
|
DownloadType.Nzb,
|
|
It.IsAny<Data.Enums.DownloadClient>(),
|
|
It.IsAny<Torrent>()))
|
|
.ReturnsAsync(new Torrent());
|
|
|
|
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
|
|
mocks.TorrentDataMock.Object,
|
|
mocks.DownloadsMock.Object,
|
|
mocks.ProcessFactoryMock.Object,
|
|
new MockFileSystem(),
|
|
mocks.EnricherMock.Object,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!);
|
|
|
|
// Act
|
|
await torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent);
|
|
|
|
// Assert
|
|
mocks.TorrentDataMock.Verify(t => t.Add(null,
|
|
It.IsAny<String>(),
|
|
It.IsAny<String>(),
|
|
true,
|
|
DownloadType.Nzb,
|
|
It.IsAny<Data.Enums.DownloadClient>(),
|
|
It.IsAny<Torrent>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddNzbLinkToDebridQueue_ShouldSetDownloadTypeNzb()
|
|
{
|
|
// Arrange
|
|
var mocks = new Mocks();
|
|
var torrent = new Torrent
|
|
{
|
|
TorrentId = Guid.NewGuid()
|
|
};
|
|
var link = "http://example.com/test.nzb";
|
|
|
|
mocks.TorrentDataMock.Setup(t => t.Add(It.IsAny<String>(),
|
|
It.IsAny<String>(),
|
|
It.IsAny<String>(),
|
|
false,
|
|
DownloadType.Nzb,
|
|
It.IsAny<Data.Enums.DownloadClient>(),
|
|
It.IsAny<Torrent>()))
|
|
.ReturnsAsync(new Torrent());
|
|
|
|
var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object,
|
|
mocks.TorrentDataMock.Object,
|
|
mocks.DownloadsMock.Object,
|
|
mocks.ProcessFactoryMock.Object,
|
|
new MockFileSystem(),
|
|
mocks.EnricherMock.Object,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!,
|
|
null!);
|
|
|
|
// Act
|
|
await torrents.AddNzbLinkToDebridQueue(link, torrent);
|
|
|
|
// Assert
|
|
mocks.TorrentDataMock.Verify(t => t.Add(null,
|
|
It.IsAny<String>(),
|
|
link,
|
|
false,
|
|
DownloadType.Nzb,
|
|
It.IsAny<Data.Enums.DownloadClient>(),
|
|
It.IsAny<Torrent>()), Times.Once);
|
|
}
|
|
}
|