rdt-client/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs
Antonin Lenfant-Kodia b2a725c285 Simplify comments
2026-06-15 15:58:23 +02:00

404 lines
19 KiB
C#

using System.IO.Abstractions.TestingHelpers;
using Moq;
using RdtClient.Service.Helpers;
using RdtClient.Service.Services.Downloaders;
using Synology.Api.Client;
using Synology.Api.Client.ApiDescription;
using Synology.Api.Client.Apis.DownloadStation;
using Synology.Api.Client.Apis.DownloadStation.Task.Models;
using Synology.Api.Client.Apis.FileStation;
using Synology.Api.Client.Apis.FileStation.CreateFolder;
using Synology.Api.Client.Apis.FileStation.CreateFolder.Models;
using Synology.Api.Client.Exceptions;
namespace RdtClient.Service.Test.Services.Downloaders;
internal class Mocks
{
public readonly Mock<IFileStationCreateFolderEndpoint> CreateFolderEndpointMock = new();
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);
var fileStationApiMock = new Mock<IFileStationApi>();
fileStationApiMock.Setup(a => a.CreateFolderEndpoint()).Returns(CreateFolderEndpointMock.Object);
SynologyClientMock.Setup(c => c.FileStationApi()).Returns(fileStationApiMock.Object);
CreateFolderEndpointMock.Setup(e => e.CreateAsync(It.IsAny<String[]>(), It.IsAny<Boolean>()))
.ReturnsAsync(new FileStationCreateFolderCreateResponse());
}
}
internal 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_WhenTaskAlreadyExists_AdoptsItAndReturnsGid()
{
// 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 gid = await downloadStationDownloader.Download();
// Assert: an existing task must be reused (idempotent), not re-created or thrown as "already added".
// Throwing would brick every retry that reuses the gid because the DownloadStation delete fails to
// deserialize its response, so the task is never actually removed.
Assert.Equal(mocks.Gid, gid);
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());
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_CreatesDestinationFolderBeforeCreatingTask()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new());
mocks.TaskEndpointMock.Setup(t => t.ListAsync()).ReturnsAsync(new DownloadStationTaskListResponse { Total = 0, Offset = 0 });
var calls = new List<String>();
mocks.CreateFolderEndpointMock.Setup(e => e.CreateAsync(It.IsAny<String[]>(), It.IsAny<Boolean>()))
.ReturnsAsync(new FileStationCreateFolderCreateResponse())
.Callback(() => calls.Add("folder"));
mocks.TaskEndpointMock.Setup(t => t.CreateAsync(It.IsAny<DownloadStationTaskCreateRequest>()))
.ReturnsAsync(new DownloadStationTaskCreateResponse { TaskId = [mocks.Gid] })
.Callback(() => calls.Add("task"));
var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid,
"https://fake.url/file.txt",
"/Media/Downloads/Torrents/MyTorrent/file.txt",
"/path/to/file.txt",
"download-path",
mocks.SynologyClientMock.Object);
// Act
await downloadStationDownloader.Download();
// Assert
Assert.Equal(["folder", "task"], calls);
mocks.CreateFolderEndpointMock.Verify(e => e.CreateAsync(It.Is<String[]>(p => p.Single() == "/Media/Downloads/Torrents/MyTorrent"), true), Times.Once);
}
[Fact]
public async Task Download_WhenSynologyReportsSessionError119_ReAuthenticatesAndRetries()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new());
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] });
// The first folder-create fails with DSM error 119 ("SID not found"); after re-auth the retry succeeds.
mocks.CreateFolderEndpointMock.SetupSequence(e => e.CreateAsync(It.IsAny<String[]>(), It.IsAny<Boolean>()))
.ThrowsAsync(new SynologyApiException(new Mock<IApiInfo>().Object, "FileStation.CreateFolder", 119, "SID not found"))
.ReturnsAsync(new FileStationCreateFolderCreateResponse());
var reacquired = 0;
var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid,
"https://fake.url/file.txt",
"/Media/Downloads/Torrents/MyTorrent/file.txt",
"/path/to/file.txt",
"download-path",
mocks.SynologyClientMock.Object,
reacquireClient: () =>
{
reacquired++;
return Task.FromResult(mocks.SynologyClientMock.Object);
});
// Act
var result = await downloadStationDownloader.Download();
// Assert
Assert.Equal(mocks.Gid, result);
Assert.Equal(1, reacquired);
mocks.CreateFolderEndpointMock.Verify(e => e.CreateAsync(It.IsAny<String[]>(), It.IsAny<Boolean>()), Times.Exactly(2));
}
[Fact]
public async Task Download_After5Tries_Throws()
{
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new());
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())
.ThrowsAsync(new())
.ThrowsAsync(new())
.ThrowsAsync(new())
.ThrowsAsync(new());
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());
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())
.ThrowsAsync(new())
.ThrowsAsync(new())
.ThrowsAsync(new())
.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();
}
[Theory]
[InlineData(DownloadStationTaskStatus.Finished)]
[InlineData(DownloadStationTaskStatus.Downloaded)]
[InlineData(DownloadStationTaskStatus.Seeding)]
public async Task Update_WhenCompleteAndFileExists_CompletesWithoutError(DownloadStationTaskStatus status)
{
// Arrange
var mocks = new Mocks();
const String filePath = "/data/downloads/file.mkv";
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ReturnsAsync(new DownloadStationTask { Status = status });
var fileSystem = new MockFileSystem();
fileSystem.AddFile(filePath, new MockFileData("content"));
var downloader = new DownloadStationDownloader(mocks.Gid, "https://fake.url/file.mkv", "/remote/file.mkv", filePath, "download-path",
mocks.SynologyClientMock.Object, new FakeDelayProvider(), fileSystem);
DownloadCompleteEventArgs? completed = null;
downloader.DownloadComplete += (_, e) => completed = e;
// Act
await downloader.Update();
// Assert
Assert.NotNull(completed);
Assert.Null(completed!.Error);
}
[Fact]
public async Task Update_WhenFinishedButFileMissing_CompletesWithError()
{
// Arrange
var mocks = new Mocks();
const String filePath = "/data/downloads/file.mkv";
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ReturnsAsync(new DownloadStationTask { Status = DownloadStationTaskStatus.Finished });
var downloader = new DownloadStationDownloader(mocks.Gid, "https://fake.url/file.mkv", "/remote/file.mkv", filePath, "download-path",
mocks.SynologyClientMock.Object, new FakeDelayProvider(), new MockFileSystem());
DownloadCompleteEventArgs? completed = null;
downloader.DownloadComplete += (_, e) => completed = e;
// Act
await downloader.Update();
// Assert
Assert.NotNull(completed);
Assert.NotNull(completed!.Error);
Assert.Contains("no file was found", completed.Error!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Update_WhenErrorDetailSet_CancelsAndReportsError()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid))
.ReturnsAsync(new DownloadStationTask
{
Status = DownloadStationTaskStatus.Downloading,
StatusExtra = new DownloadStationTaskStatusExtra { ErrorDetail = "torrent_duplicate" }
});
mocks.TaskEndpointMock.Setup(t => t.DeleteAsync(It.IsAny<DownloadStationTaskDeleteRequest>()))
.ReturnsAsync(new DownloadStationTaskDeleteResponse());
var downloader = new DownloadStationDownloader(mocks.Gid, "https://fake.url/file.mkv", "/remote/file.mkv", "/data/downloads/file.mkv", "download-path",
mocks.SynologyClientMock.Object, new FakeDelayProvider(), new MockFileSystem());
DownloadCompleteEventArgs? completed = null;
downloader.DownloadComplete += (_, e) => completed = e;
// Act
await downloader.Update();
// Assert
Assert.NotNull(completed);
Assert.Contains("torrent_duplicate", completed!.Error!, StringComparison.OrdinalIgnoreCase);
mocks.TaskEndpointMock.Verify(t => t.DeleteAsync(It.IsAny<DownloadStationTaskDeleteRequest>()), Times.Once);
}
[Fact]
public async Task Update_WhenCaptchaNeeded_ReportsError()
{
// Arrange
var mocks = new Mocks();
mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ReturnsAsync(new DownloadStationTask { Status = DownloadStationTaskStatus.CaptchaNeeded });
mocks.TaskEndpointMock.Setup(t => t.DeleteAsync(It.IsAny<DownloadStationTaskDeleteRequest>())).ReturnsAsync(new DownloadStationTaskDeleteResponse());
var downloader = new DownloadStationDownloader(mocks.Gid, "https://fake.url/file.mkv", "/remote/file.mkv", "/data/downloads/file.mkv", "download-path",
mocks.SynologyClientMock.Object, new FakeDelayProvider(), new MockFileSystem());
DownloadCompleteEventArgs? completed = null;
downloader.DownloadComplete += (_, e) => completed = e;
// Act
await downloader.Update();
// Assert
Assert.NotNull(completed);
Assert.Contains("captcha", completed!.Error!, StringComparison.OrdinalIgnoreCase);
}
}