using System.Diagnostics; using System.IO.Abstractions.TestingHelpers; using System.Runtime.InteropServices; 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 DownloadClient = RdtClient.Data.Enums.DownloadClient; using TorrentsService = RdtClient.Service.Services.Torrents; namespace RdtClient.Service.Test.Services; internal class Mocks { public readonly Mock DownloadsMock; public readonly Mock EnricherMock; public readonly Mock ProcessFactoryMock; public readonly Mock ProcessMock; public readonly Mock TorrentDataMock; public readonly Mock> 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> TorrentAndDownload() { var torrent = new Torrent { RdName = "TestTorrent", Hash = "123ABC", Category = "Movies", RdSize = 100, TorrentId = Guid.Empty }; List 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 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)); mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads); var category = torrent.Category!; var torrentName = torrent.RdName!; var fileName = downloads[0].FileName!; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { settings.DownloadClient.DownloadPath = @"C:\Downloads"; } var downloadPath = Path.Combine(settings.DownloadClient.DownloadPath, category); var torrentPath = Path.Combine(downloadPath, torrentName); var filePath = Path.Combine(torrentPath, fileName); var fileSystemMock = new MockFileSystem(new Dictionary { { 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!, new TestSettings(), new TorrentRunnerState()); mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny())).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 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)); mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads); var downloadPath = Path.Combine(settings.DownloadClient.DownloadPath, torrent.Category ?? ""); var torrentPath = Path.Combine(downloadPath, torrent.RdName ?? ""); var filePath = Path.Combine(torrentPath, downloads[0].FileName ?? ""); var fileSystemMock = new MockFileSystem(new Dictionary { { 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!, new TestSettings(), new TorrentRunnerState()); //Act await torrents.RunTorrentComplete(torrent.TorrentId, settings); //Assert mocks.ProcessFactoryMock.VerifyNoOtherCalls(); } [Theory] [MemberData(nameof(TorrentAndDownload))] public async Task RunTorrentComplete_WhenStdOut_Logs(Torrent torrent, List 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)); mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads); var downloadPath = Path.Combine(settings.DownloadClient.DownloadPath, torrent.Category ?? ""); var torrentPath = Path.Combine(downloadPath, torrent.RdName ?? ""); var filePath = Path.Combine(torrentPath, downloads[0].FileName ?? ""); var fileSystemMock = new MockFileSystem(new Dictionary { { 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!, new TestSettings(), new TorrentRunnerState()); mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny())) .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 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)); mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads); var downloadPath = Path.Combine(settings.DownloadClient.DownloadPath, torrent.Category ?? ""); var torrentPath = Path.Combine(downloadPath, torrent.RdName ?? ""); var filePath = Path.Combine(torrentPath, downloads[0].FileName ?? ""); var fileSystemMock = new MockFileSystem(new Dictionary { { 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!, new TestSettings(), new TorrentRunnerState()); mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny())) .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 = "\r\n\r\n \r\n Test NZB Title\r\n \r\n"; var bytes = Encoding.UTF8.GetBytes(nzbContent); mocks.TorrentDataMock.Setup(t => t.Add(It.IsAny(), It.IsAny(), It.IsAny(), true, DownloadType.Nzb, It.IsAny(), It.IsAny())) .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!, new TestSettings(), new TorrentRunnerState()); // Act await torrents.AddNzbFileToDebridQueue(bytes, "filename.nzb", torrent); // Assert mocks.TorrentDataMock.Verify(t => t.Add(null, It.IsAny(), It.IsAny(), true, DownloadType.Nzb, It.IsAny(), It.IsAny()), 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(), It.IsAny(), It.IsAny(), false, DownloadType.Nzb, It.IsAny(), It.IsAny())) .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!, new TestSettings(), new TorrentRunnerState()); // Act await torrents.AddNzbLinkToDebridQueue(link, torrent); // Assert mocks.TorrentDataMock.Verify(t => t.Add(null, It.IsAny(), link, false, DownloadType.Nzb, It.IsAny(), It.IsAny()), Times.Once); } }