mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-12 05:19:43 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6873c5af6c | ||
|
|
506825c219 | ||
|
|
b6d840bea9 | ||
|
|
e18384b327 | ||
|
|
acd42aa39a | ||
|
|
54748b6112 | ||
|
|
3d1d1cab90 | ||
|
|
3c52d49ea6 | ||
|
|
5e418ae940 |
No files matched your search
+566
@@ -0,0 +1,566 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using QBittorrent.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using TransmissionClient = Transmission.API.RPC.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
/// <summary>
|
||||
/// A failing torrent-list call must throw, never report zero torrents.
|
||||
/// </summary>
|
||||
public sealed class TorrentListErrorSurfacingTests
|
||||
{
|
||||
private const string Html = "<html><body>Sign in to continue</body></html>";
|
||||
|
||||
private static DownloadClientConfig Config(string host, DownloadClientTypeName typeName) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Test {typeName}",
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri(host),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = string.Empty,
|
||||
};
|
||||
|
||||
private static HttpClient Client(FakeHttpMessageHandler handler) => new(handler);
|
||||
|
||||
private static HttpResponseMessage Respond(HttpStatusCode status, string body, string contentType = "text/plain") =>
|
||||
new(status) { Content = new StringContent(body, Encoding.UTF8, contentType) };
|
||||
|
||||
#region rTorrent
|
||||
|
||||
private static RTorrentClient RTorrent(FakeHttpMessageHandler handler) =>
|
||||
new(Config("http://localhost/RPC2", DownloadClientTypeName.rTorrent), Client(handler));
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
public async Task RTorrent_ListCallReturnsErrorStatus_Throws(HttpStatusCode status)
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(status, "failure")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => RTorrent(handler).GetAllTorrentsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RTorrent_ListCallReturnsLoginHtml_Throws()
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, Html, "text/html")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => RTorrent(handler).GetAllTorrentsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RTorrent_ListCallReturnsEmptyBody_Throws()
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, string.Empty)));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => RTorrent(handler).GetAllTorrentsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RTorrent_ListCallReturnsXmlRpcFault_Throws()
|
||||
{
|
||||
const string fault = """
|
||||
<?xml version="1.0"?>
|
||||
<methodResponse><fault><value><struct>
|
||||
<member><name>faultCode</name><value><i4>-501</i4></value></member>
|
||||
<member><name>faultString</name><value><string>Unsupported target type found.</string></value></member>
|
||||
</struct></value></fault></methodResponse>
|
||||
""";
|
||||
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, fault, "text/xml")));
|
||||
|
||||
Exception exception = await Should.ThrowAsync<Exception>(() => RTorrent(handler).GetAllTorrentsAsync());
|
||||
exception.Message.ShouldContain("Unsupported target type found.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RTorrent_ListCallReturnsNonArrayValue_Throws()
|
||||
{
|
||||
const string scalar = """
|
||||
<?xml version="1.0"?>
|
||||
<methodResponse><params><param><value><string>not a list</string></value></param></params></methodResponse>
|
||||
""";
|
||||
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, scalar, "text/xml")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => RTorrent(handler).GetAllTorrentsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RTorrent_ListCallReturnsRowWithMissingFields_Throws()
|
||||
{
|
||||
// 12 values where the client requests 13 fields.
|
||||
string values = string.Concat(Enumerable.Repeat("<value><string>x</string></value>", 12));
|
||||
string response = $"""
|
||||
<?xml version="1.0"?>
|
||||
<methodResponse><params><param><value><array><data>
|
||||
<value><array><data>{values}</data></array></value>
|
||||
</data></array></value></param></params></methodResponse>
|
||||
""";
|
||||
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, response, "text/xml")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => RTorrent(handler).GetAllTorrentsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RTorrent_ListCallReturnsEmptyArray_ReturnsEmptyWithoutThrowing()
|
||||
{
|
||||
const string empty = """
|
||||
<?xml version="1.0"?>
|
||||
<methodResponse><params><param><value><array><data/></array></value></param></params></methodResponse>
|
||||
""";
|
||||
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, empty, "text/xml")));
|
||||
|
||||
List<Cleanuparr.Domain.Entities.RTorrent.Response.RTorrentTorrent> torrents =
|
||||
await RTorrent(handler).GetAllTorrentsAsync();
|
||||
|
||||
torrents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deluge
|
||||
|
||||
private static DelugeClient Deluge(FakeHttpMessageHandler handler) =>
|
||||
new(Config("http://localhost:8112", DownloadClientTypeName.Deluge), Client(handler));
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
public async Task Deluge_ListCallReturnsErrorStatus_Throws(HttpStatusCode status)
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(status, "failure")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => Deluge(handler).GetStatusForAllTorrents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deluge_ListCallReturnsLoginHtml_Throws()
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, Html, "text/html")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => Deluge(handler).GetStatusForAllTorrents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deluge_ListCallReturnsEmptyBody_Throws()
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, string.Empty)));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => Deluge(handler).GetStatusForAllTorrents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deluge_ListCallReturnsRpcError_Throws()
|
||||
{
|
||||
const string rpcError = """
|
||||
{"result": null, "error": {"message": "Not connected to a daemon", "code": 1}, "id": 1}
|
||||
""";
|
||||
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, rpcError, "application/json")));
|
||||
|
||||
Exception exception = await Should.ThrowAsync<Exception>(() => Deluge(handler).GetStatusForAllTorrents());
|
||||
exception.Message.ShouldContain("Not connected to a daemon");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deluge_ListCallReturnsMismatchedRequestId_Throws()
|
||||
{
|
||||
const string desync = """
|
||||
{"result": {}, "error": null, "id": 99}
|
||||
""";
|
||||
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, desync, "application/json")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => Deluge(handler).GetStatusForAllTorrents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deluge_ListCallReturnsNullResult_ReturnsNull()
|
||||
{
|
||||
// A null RPC result carries no error, so the client hands back null.
|
||||
// DelugeServiceDC rejects it.
|
||||
const string nullResult = """
|
||||
{"result": null, "error": null, "id": 1}
|
||||
""";
|
||||
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, nullResult, "application/json")));
|
||||
|
||||
List<Cleanuparr.Domain.Entities.Deluge.Response.DownloadStatus>? torrents =
|
||||
await Deluge(handler).GetStatusForAllTorrents();
|
||||
|
||||
torrents.ShouldBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region qBittorrent
|
||||
|
||||
private static IQBittorrentClientWrapper QBit(FakeHttpMessageHandler handler) =>
|
||||
new QBittorrentClientWrapper(new QBittorrentClient(Client(handler), new Uri("http://localhost:8090")));
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
public async Task QBit_ListCallReturnsErrorStatus_Throws(HttpStatusCode status)
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(status, "failure")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => QBit(handler).GetTorrentListAsync(new TorrentListQuery()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QBit_ListCallReturnsLoginHtml_Throws()
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, Html, "text/html")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => QBit(handler).GetTorrentListAsync(new TorrentListQuery()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QBit_ListCallReturnsEmptyBody_ClientHandsBackNull()
|
||||
{
|
||||
// The library hands back null instead of throwing.
|
||||
// QBitServiceDC rejects the null itself.
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, string.Empty)));
|
||||
|
||||
IReadOnlyList<TorrentInfo> list = await QBit(handler).GetTorrentListAsync(new TorrentListQuery());
|
||||
|
||||
list.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QBit_ClientHandsBackNullList_ServiceThrows()
|
||||
{
|
||||
using QBitServiceFixture fixture = new();
|
||||
QBitService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetTorrentListAsync(Arg.Any<TorrentListQuery>()).Returns((IReadOnlyList<TorrentInfo>)null!);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QBit_ReportsTorrentsWithoutHashes_Throws()
|
||||
{
|
||||
using QBitServiceFixture fixture = new();
|
||||
QBitService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetTorrentListAsync(Arg.Any<TorrentListQuery>())
|
||||
.Returns(new List<TorrentInfo> { new() });
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Transmission
|
||||
|
||||
private static TransmissionClient Transmission(FakeHttpMessageHandler handler) =>
|
||||
new(Client(handler), "http://localhost:9091/transmission/rpc", login: "admin", password: "admin");
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
public async Task Transmission_ListCallReturnsErrorStatus_Throws(HttpStatusCode status)
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(status, "failure")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => Transmission(handler).TorrentGetAsync(["id", "hashString"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transmission_ListCallReturnsLoginHtml_Throws()
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, Html, "text/html")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => Transmission(handler).TorrentGetAsync(["id", "hashString"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transmission_ListCallReturnsEmptyBody_Throws()
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(HttpStatusCode.OK, string.Empty)));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => Transmission(handler).TorrentGetAsync(["id", "hashString"]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region uTorrent
|
||||
|
||||
private static UTorrentHttpService UTorrentHttp(FakeHttpMessageHandler handler) =>
|
||||
new(Client(handler),
|
||||
Config("http://localhost:8083", DownloadClientTypeName.uTorrent),
|
||||
Substitute.For<ILogger<UTorrentHttpService>>());
|
||||
|
||||
private static UTorrentResponseParser UTorrentParser() =>
|
||||
new(Substitute.For<ILogger<UTorrentResponseParser>>());
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
public async Task UTorrent_ListCallReturnsErrorStatus_Throws(HttpStatusCode status)
|
||||
{
|
||||
FakeHttpMessageHandler handler = new();
|
||||
handler.SetupResponse((_, _) => Task.FromResult(Respond(status, "failure")));
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => UTorrentHttp(handler)
|
||||
.SendRawRequestAsync(UTorrentRequestFactory.CreateTorrentListRequest(), "guid=abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UTorrent_ParserGetsLoginHtml_Throws()
|
||||
{
|
||||
Should.Throw<Exception>(() => UTorrentParser().ParseTorrentList(Html));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UTorrent_ParserGetsEmptyBody_Throws()
|
||||
{
|
||||
Should.Throw<Exception>(() => UTorrentParser().ParseTorrentList(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UTorrent_ParserGetsRowWithMissingFields_Throws()
|
||||
{
|
||||
// 26 fields where the parser requires 27.
|
||||
string row = string.Join(",", Enumerable.Repeat("\"x\"", 26));
|
||||
string json = $"{{\"build\":1,\"torrents\":[[{row}]]}}";
|
||||
|
||||
Should.Throw<Exception>(() => UTorrentParser().ParseTorrentList(json));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UTorrent_ParserGetsRowWithExactFieldCount_ParsesIt()
|
||||
{
|
||||
const string json = """
|
||||
{"build":1,"torrents":[[
|
||||
"ABC123", 201, "Some.Release", 1024, 1000, 1024, 512, 500, 0, 0, 0, "tv",
|
||||
0, 0, 0, 0, 65536, 1, 0, "", "", "Seeding", "", 1700000000, 1700000001, "", "/downloads"
|
||||
]]}
|
||||
""";
|
||||
|
||||
Cleanuparr.Domain.Entities.UTorrent.Response.TorrentListResponse response =
|
||||
UTorrentParser().ParseTorrentList(json);
|
||||
|
||||
response.Torrents.Count.ShouldBe(1);
|
||||
response.Torrents[0].Hash.ShouldBe("ABC123");
|
||||
response.Torrents[0].SavePath.ShouldBe("/downloads");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UTorrent_ParserGetsEmptyTorrentArray_ReturnsEmptyWithoutThrowing()
|
||||
{
|
||||
Cleanuparr.Domain.Entities.UTorrent.Response.TorrentListResponse response =
|
||||
UTorrentParser().ParseTorrentList("{\"build\":1,\"torrents\":[]}");
|
||||
|
||||
response.Torrents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Zero-collapse guard
|
||||
|
||||
// Rows whose hash did not bind used to be filtered away.
|
||||
// A list of only such rows became an empty list.
|
||||
|
||||
[Fact]
|
||||
public async Task RTorrent_ReportsTorrentsWithoutHashes_Throws()
|
||||
{
|
||||
using RTorrentServiceFixture fixture = new();
|
||||
RTorrentService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetAllTorrentsAsync().Returns([
|
||||
new Cleanuparr.Domain.Entities.RTorrent.Response.RTorrentTorrent { Hash = "", Name = "no-hash" },
|
||||
]);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deluge_ReportsTorrentsWithoutHashes_Throws()
|
||||
{
|
||||
using DelugeServiceFixture fixture = new();
|
||||
DelugeService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetStatusForAllTorrents().Returns([
|
||||
new Cleanuparr.Domain.Entities.Deluge.Response.DownloadStatus { Hash = "", Name = "no-hash" },
|
||||
]);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deluge_ClientHandsBackNullList_ServiceThrows()
|
||||
{
|
||||
using DelugeServiceFixture fixture = new();
|
||||
DelugeService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetStatusForAllTorrents()
|
||||
.Returns((List<Cleanuparr.Domain.Entities.Deluge.Response.DownloadStatus>?)null);
|
||||
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.DelugeClientException>(() => sut.GetAllTorrentsLite());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transmission_ClientHandsBackNullList_ServiceThrows()
|
||||
{
|
||||
using TransmissionServiceFixture fixture = new();
|
||||
TransmissionService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
|
||||
.Returns((Transmission.API.RPC.Entity.TransmissionTorrents?)null);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transmission_ReportsTorrentsWithoutHashes_Throws()
|
||||
{
|
||||
using TransmissionServiceFixture fixture = new();
|
||||
TransmissionService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
|
||||
.Returns(new Transmission.API.RPC.Entity.TransmissionTorrents
|
||||
{
|
||||
Torrents = [new Transmission.API.RPC.Entity.TorrentInfo { HashString = "" }],
|
||||
});
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UTorrent_ReportsTorrentsWithoutHashes_Throws()
|
||||
{
|
||||
using UTorrentServiceFixture fixture = new();
|
||||
UTorrentService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetTorrentsAsync().Returns([
|
||||
new Cleanuparr.Domain.Entities.UTorrent.Response.UTorrentItem { Hash = "", Name = "no-hash" },
|
||||
]);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty client
|
||||
|
||||
// The counterpart to the guard above: a client holding nothing is not a faulty client.
|
||||
// Issue #746 came from reading these two states as one.
|
||||
|
||||
[Fact]
|
||||
public async Task RTorrent_ReportsNoTorrents_ReturnsEmpty()
|
||||
{
|
||||
using RTorrentServiceFixture fixture = new();
|
||||
RTorrentService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetAllTorrentsAsync()
|
||||
.Returns(new List<Cleanuparr.Domain.Entities.RTorrent.Response.RTorrentTorrent>());
|
||||
|
||||
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
|
||||
|
||||
torrents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deluge_ReportsNoTorrents_ReturnsEmpty()
|
||||
{
|
||||
using DelugeServiceFixture fixture = new();
|
||||
DelugeService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetStatusForAllTorrents()
|
||||
.Returns(new List<Cleanuparr.Domain.Entities.Deluge.Response.DownloadStatus>());
|
||||
|
||||
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
|
||||
|
||||
torrents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QBit_ReportsNoTorrents_ReturnsEmpty()
|
||||
{
|
||||
using QBitServiceFixture fixture = new();
|
||||
QBitService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetTorrentListAsync(Arg.Any<TorrentListQuery>())
|
||||
.Returns(new List<TorrentInfo>());
|
||||
|
||||
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
|
||||
|
||||
torrents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transmission_ReportsNoTorrents_ReturnsEmpty()
|
||||
{
|
||||
using TransmissionServiceFixture fixture = new();
|
||||
TransmissionService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
|
||||
.Returns(new Transmission.API.RPC.Entity.TransmissionTorrents { Torrents = [] });
|
||||
|
||||
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
|
||||
|
||||
torrents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UTorrent_ReportsNoTorrents_ReturnsEmpty()
|
||||
{
|
||||
using UTorrentServiceFixture fixture = new();
|
||||
UTorrentService sut = fixture.CreateSut();
|
||||
fixture.ClientWrapper.GetTorrentsAsync()
|
||||
.Returns(new List<Cleanuparr.Domain.Entities.UTorrent.Response.UTorrentItem>());
|
||||
|
||||
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
|
||||
|
||||
torrents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+6
-6
@@ -92,16 +92,16 @@ public class UTorrentResponseParserTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTorrentList_RowShorterThan27Fields_SkipsRow()
|
||||
public void ParseTorrentList_RowShorterThan27Fields_Throws()
|
||||
{
|
||||
// Arrange — only 5 fields per torrent
|
||||
// Only 5 fields per torrent.
|
||||
const string json = """{"build": 1, "torrents": [["HASH", 0, "name", 100, 1000]], "label": []}""";
|
||||
|
||||
// Act
|
||||
var response = _parser.ParseTorrentList(json);
|
||||
// Skipping the row would report zero torrents.
|
||||
UTorrentParsingException exception =
|
||||
Should.Throw<UTorrentParsingException>(() => _parser.ParseTorrentList(json));
|
||||
|
||||
// Assert — short rows are silently skipped
|
||||
response.Torrents.ShouldBeEmpty();
|
||||
exception.Message.ShouldContain("27");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+154
-14
@@ -60,12 +60,8 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
|
||||
_fixture.DryRunInterceptor,
|
||||
_fixture.LazyLibrarianService);
|
||||
|
||||
private async Task ExecuteWithTimeAdvance(DownloadCleaner sut)
|
||||
{
|
||||
var task = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
|
||||
await task;
|
||||
}
|
||||
private Task ExecuteWithTimeAdvance(DownloadCleaner sut) =>
|
||||
_fixture.TimeProvider.AdvanceUntilCompleted(sut.ExecuteAsync());
|
||||
|
||||
private static ITorrentItemWrapper MakeTorrent(string name, string savePath)
|
||||
{
|
||||
@@ -497,17 +493,17 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
|
||||
File.Exists(fileThatWouldBeMoved).ShouldBeTrue();
|
||||
(Directory.Exists(orphanedDir) && Directory.GetFiles(orphanedDir).Length > 0).ShouldBeFalse();
|
||||
_fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Error, "Failed to get torrents").ShouldBeTrue();
|
||||
_fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Warning, "torrents are unavailable or empty").ShouldBeTrue();
|
||||
_fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Warning, "torrents are unavailable").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanedFiles_DownloadClientReturnsZeroTorrents_ScanIsSkipped()
|
||||
public async Task OrphanedFiles_DownloadClientReturnsZeroTorrents_ScanStillRuns()
|
||||
{
|
||||
string scanDir = Path.Combine(_tempRoot, "downloads");
|
||||
string orphanedDir = Path.Combine(_tempRoot, "orphaned");
|
||||
Directory.CreateDirectory(scanDir);
|
||||
string fileThatWouldBeMoved = Path.Combine(scanDir, "would-be-orphan.mkv");
|
||||
File.WriteAllText(fileThatWouldBeMoved, "x");
|
||||
string orphan = Path.Combine(scanDir, "orphan.mkv");
|
||||
File.WriteAllText(orphan, "x");
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
DownloadClientConfig dbClient = _fixture.DataContext.DownloadClients.First();
|
||||
@@ -521,10 +517,154 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
|
||||
DownloadCleaner sut = CreateSut();
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
File.Exists(fileThatWouldBeMoved).ShouldBeTrue();
|
||||
(Directory.Exists(orphanedDir) && Directory.GetFiles(orphanedDir).Length > 0).ShouldBeFalse();
|
||||
_fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Debug, "No torrents found").ShouldBeTrue();
|
||||
_fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Warning, "torrents are unavailable or empty").ShouldBeTrue();
|
||||
File.Exists(orphan).ShouldBeFalse();
|
||||
File.Exists(Path.Combine(orphanedDir, "orphan.mkv")).ShouldBeTrue();
|
||||
_fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Warning, "No torrents reported").ShouldBeTrue();
|
||||
_fixture.OrphanedFilesLogger.HasNoLogContaining(LogLevel.Warning, "torrents are unavailable").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanedFiles_ZeroTorrents_PurgeStillRuns()
|
||||
{
|
||||
_fixture.TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
string scanDir = Path.Combine(_tempRoot, "downloads");
|
||||
string orphanedDir = Path.Combine(_tempRoot, "orphaned");
|
||||
Directory.CreateDirectory(scanDir);
|
||||
Directory.CreateDirectory(orphanedDir);
|
||||
|
||||
string agedOrphan = Path.Combine(orphanedDir, "aged.bin");
|
||||
File.WriteAllText(agedOrphan, "old");
|
||||
File.SetLastWriteTimeUtc(agedOrphan, new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
DownloadClientConfig dbClient = _fixture.DataContext.DownloadClients.First();
|
||||
TestDataContextFactory.AddOrphanedFilesConfig(
|
||||
_fixture.DataContext, dbClient,
|
||||
scanDirectories: [scanDir],
|
||||
orphanedDirectory: orphanedDir,
|
||||
purgeAfterHours: 24);
|
||||
|
||||
SetupDownloadService(dbClient, []);
|
||||
|
||||
DownloadCleaner sut = CreateSut();
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
File.Exists(agedOrphan).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanedFiles_ZeroTorrents_ExcludePatternsAndMinFileAgeStillApply()
|
||||
{
|
||||
_fixture.TimeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
string scanDir = Path.Combine(_tempRoot, "downloads");
|
||||
string orphanedDir = Path.Combine(_tempRoot, "orphaned");
|
||||
Directory.CreateDirectory(scanDir);
|
||||
|
||||
DateTime aged = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
string agedOrphan = Path.Combine(scanDir, "aged.bin");
|
||||
File.WriteAllText(agedOrphan, "x");
|
||||
File.SetLastWriteTimeUtc(agedOrphan, aged);
|
||||
File.SetCreationTimeUtc(agedOrphan, aged);
|
||||
|
||||
string agedExcluded = Path.Combine(scanDir, "metadata.nfo");
|
||||
File.WriteAllText(agedExcluded, "x");
|
||||
File.SetLastWriteTimeUtc(agedExcluded, aged);
|
||||
File.SetCreationTimeUtc(agedExcluded, aged);
|
||||
|
||||
string freshOrphan = Path.Combine(scanDir, "fresh.bin");
|
||||
File.WriteAllText(freshOrphan, "x");
|
||||
File.SetLastWriteTimeUtc(freshOrphan, new DateTime(2026, 1, 1, 11, 45, 0, DateTimeKind.Utc));
|
||||
File.SetCreationTimeUtc(freshOrphan, new DateTime(2026, 1, 1, 11, 45, 0, DateTimeKind.Utc));
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
DownloadClientConfig dbClient = _fixture.DataContext.DownloadClients.First();
|
||||
TestDataContextFactory.AddOrphanedFilesConfig(
|
||||
_fixture.DataContext, dbClient,
|
||||
scanDirectories: [scanDir],
|
||||
orphanedDirectory: orphanedDir,
|
||||
excludePatterns: ["*.nfo"],
|
||||
minFileAgeHours: 1);
|
||||
|
||||
SetupDownloadService(dbClient, []);
|
||||
|
||||
DownloadCleaner sut = CreateSut();
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
File.Exists(agedOrphan).ShouldBeFalse();
|
||||
File.Exists(Path.Combine(orphanedDir, "aged.bin")).ShouldBeTrue();
|
||||
File.Exists(agedExcluded).ShouldBeTrue();
|
||||
File.Exists(freshOrphan).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanedFiles_ZeroTorrents_OrphanedDirectoryItselfIsNotMoved()
|
||||
{
|
||||
string scanDir = Path.Combine(_tempRoot, "downloads");
|
||||
string orphanedDir = Path.Combine(scanDir, "orphaned");
|
||||
Directory.CreateDirectory(orphanedDir);
|
||||
string alreadyQuarantined = Path.Combine(orphanedDir, "previous.bin");
|
||||
File.WriteAllText(alreadyQuarantined, "x");
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
DownloadClientConfig dbClient = _fixture.DataContext.DownloadClients.First();
|
||||
TestDataContextFactory.AddOrphanedFilesConfig(
|
||||
_fixture.DataContext, dbClient,
|
||||
scanDirectories: [scanDir],
|
||||
orphanedDirectory: orphanedDir);
|
||||
|
||||
SetupDownloadService(dbClient, []);
|
||||
|
||||
DownloadCleaner sut = CreateSut();
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
Directory.Exists(orphanedDir).ShouldBeTrue();
|
||||
File.Exists(alreadyQuarantined).ShouldBeTrue();
|
||||
Directory.Exists(Path.Combine(orphanedDir, "orphaned")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanedFiles_ThrowingClientAndEmptyClient_OnlyEmptyClientIsScanned()
|
||||
{
|
||||
string scanDirA = Path.Combine(_tempRoot, "downloads-a");
|
||||
string orphanedDirA = Path.Combine(_tempRoot, "orphaned-a");
|
||||
string scanDirB = Path.Combine(_tempRoot, "downloads-b");
|
||||
string orphanedDirB = Path.Combine(_tempRoot, "orphaned-b");
|
||||
Directory.CreateDirectory(scanDirA);
|
||||
Directory.CreateDirectory(scanDirB);
|
||||
string fileInA = Path.Combine(scanDirA, "a-orphan.mkv");
|
||||
string fileInB = Path.Combine(scanDirB, "b-orphan.mkv");
|
||||
File.WriteAllText(fileInA, "x");
|
||||
File.WriteAllText(fileInB, "x");
|
||||
|
||||
DownloadClientConfig clientA = TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "Client A");
|
||||
DownloadClientConfig clientB = TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "Client B");
|
||||
TestDataContextFactory.AddOrphanedFilesConfig(
|
||||
_fixture.DataContext, clientA,
|
||||
scanDirectories: [scanDirA],
|
||||
orphanedDirectory: orphanedDirA);
|
||||
TestDataContextFactory.AddOrphanedFilesConfig(
|
||||
_fixture.DataContext, clientB,
|
||||
scanDirectories: [scanDirB],
|
||||
orphanedDirectory: orphanedDirB);
|
||||
|
||||
IDownloadService svcA = Substitute.For<IDownloadService>();
|
||||
svcA.ClientConfig.Returns(clientA);
|
||||
svcA.LoginAsync().Returns(Task.CompletedTask);
|
||||
svcA.GetSeedingDownloads().Returns([]);
|
||||
svcA.GetAllTorrentsLite().ThrowsAsync(new HttpRequestException("connection refused"));
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(clientA).Returns(svcA);
|
||||
|
||||
SetupDownloadService(clientB, []);
|
||||
|
||||
DownloadCleaner sut = CreateSut();
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
File.Exists(fileInA).ShouldBeTrue();
|
||||
File.Exists(fileInB).ShouldBeFalse();
|
||||
Directory.GetFiles(orphanedDirB).ShouldContain(f => Path.GetFileName(f) == "b-orphan.mkv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -63,12 +63,8 @@ public class DownloadCleanerTests : IDisposable
|
||||
/// <summary>
|
||||
/// Executes the handler and advances time past the 10-second delay
|
||||
/// </summary>
|
||||
private async Task ExecuteWithTimeAdvance(DownloadCleaner sut)
|
||||
{
|
||||
var task = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
|
||||
await task;
|
||||
}
|
||||
private Task ExecuteWithTimeAdvance(DownloadCleaner sut) =>
|
||||
_fixture.TimeProvider.AdvanceUntilCompleted(sut.ExecuteAsync());
|
||||
|
||||
#region ExecuteAsync Tests (inherited from GenericHandler)
|
||||
|
||||
@@ -153,6 +149,39 @@ public class DownloadCleanerTests : IDisposable
|
||||
_logger.HasLogContaining(LogLevel.Information, "No seeding downloads found").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoSeedingDownloadsFound_OrphanedFilesStillRuns()
|
||||
{
|
||||
// Arrange
|
||||
DownloadClientConfig client = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddOrphanedFilesConfig(
|
||||
_fixture.DataContext, client,
|
||||
scanDirectories: [Path.Combine(Path.GetTempPath(), "cleanuparr-tests", Guid.NewGuid().ToString("N"))],
|
||||
orphanedDirectory: Path.Combine(Path.GetTempPath(), "cleanuparr-tests", Guid.NewGuid().ToString("N")));
|
||||
|
||||
// Bound to the persisted client, because the orphaned scan matches configs by client id
|
||||
IDownloadService mockDownloadService = Substitute.For<IDownloadService>();
|
||||
mockDownloadService.ClientConfig.Returns(client);
|
||||
mockDownloadService.LoginAsync().Returns(Task.CompletedTask);
|
||||
mockDownloadService.GetSeedingDownloads().Returns([]);
|
||||
mockDownloadService.GetAllTorrentsLite().Returns([]);
|
||||
mockDownloadService.GetClaimedPathsAsync(Arg.Any<IReadOnlyList<ITorrentItemWrapper>>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<string>>([]));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
DownloadCleaner sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - the scan reached its directory loop, so the orphaned pass is not gated on seeding downloads
|
||||
_logger.HasLogContaining(LogLevel.Information, "No seeding downloads found").ShouldBeTrue();
|
||||
_fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Warning, "Scan directory does not exist").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_FiltersOutIgnoredDownloads()
|
||||
{
|
||||
|
||||
+10
-18
@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
@@ -110,8 +111,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
|
||||
// Act - advance time past the 10s delay
|
||||
var executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
||||
|
||||
// Assert: Only the orphaned download should be passed to filter/clean
|
||||
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
|
||||
@@ -157,8 +157,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
|
||||
// Act
|
||||
var executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
||||
|
||||
// Assert: Only the non-ignored download should be processed
|
||||
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
|
||||
@@ -232,8 +231,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
|
||||
// Act
|
||||
var executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
||||
|
||||
// Assert: Full DownloadCleaned event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
@@ -311,8 +309,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
|
||||
// Act
|
||||
var executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
||||
|
||||
// Assert: Full CategoryChanged event property verification
|
||||
var events = await _fixture.EventsContext.Events.ToListAsync();
|
||||
@@ -353,8 +350,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
|
||||
// Act
|
||||
Task executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
||||
|
||||
// Assert: the torrent was stopped and stayed in the client
|
||||
downloadService.StoppedHashes.ShouldBe(["stop_hash"]);
|
||||
@@ -384,16 +380,14 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
RecordingDownloadService downloadService = SetupSeedingRuleRun(SeedingRuleAction.Stop, torrent);
|
||||
|
||||
Task firstRun = CreateSut().ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await firstRun;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(firstRun);
|
||||
|
||||
// The clients keep listing a stopped torrent as seeding.
|
||||
torrent.IsStopped.Returns(true);
|
||||
|
||||
// Act
|
||||
Task secondRun = CreateSut().ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await secondRun;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(secondRun);
|
||||
|
||||
// Assert: no second stop and no second event
|
||||
downloadService.StoppedHashes.ShouldBe(["stop_hash"]);
|
||||
@@ -418,8 +412,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
|
||||
// Act
|
||||
Task executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
||||
|
||||
// Assert
|
||||
downloadService.DeletedHashes.ShouldBe(["paused_hash"]);
|
||||
@@ -445,8 +438,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
|
||||
// Act
|
||||
Task executeTask = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15));
|
||||
await executeTask;
|
||||
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
||||
|
||||
// Assert
|
||||
downloadService.DeletedHashes.ShouldBeEmpty();
|
||||
|
||||
@@ -9,6 +9,7 @@ using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -22,6 +23,9 @@ public class StatsServiceV2Tests : IDisposable
|
||||
private readonly IJobManagementService _jobs;
|
||||
private readonly StatsService _service;
|
||||
|
||||
// Midday, so seeds a few hours apart stay inside one calendar day.
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 3, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public StatsServiceV2Tests()
|
||||
{
|
||||
_context = TestEventsContextFactory.Create();
|
||||
@@ -33,7 +37,9 @@ public class StatsServiceV2Tests : IDisposable
|
||||
_jobs = Substitute.For<IJobManagementService>();
|
||||
_jobs.GetAllJobs().ReturnsForAnyArgs(Task.FromResult<IReadOnlyList<JobInfo>>([]));
|
||||
|
||||
_service = new StatsService(Substitute.For<ILogger<StatsService>>(), _context, _health, _jobs, new SqliteDatabaseProvider());
|
||||
_service = new StatsService(
|
||||
Substitute.For<ILogger<StatsService>>(), _context, _health, _jobs, new SqliteDatabaseProvider(),
|
||||
new FakeTimeProvider(FixedNow));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -55,7 +61,7 @@ public class StatsServiceV2Tests : IDisposable
|
||||
EventType = type,
|
||||
Message = type.ToString(),
|
||||
Severity = EventSeverity.Information,
|
||||
Timestamp = timestamp ?? DateTimeOffset.UtcNow.AddHours(-1),
|
||||
Timestamp = timestamp ?? FixedNow.AddHours(-1),
|
||||
DeleteReason = deleteReason,
|
||||
CleanReason = cleanReason,
|
||||
SearchStatus = searchStatus,
|
||||
@@ -120,7 +126,7 @@ public class StatsServiceV2Tests : IDisposable
|
||||
public async Task GetStatsV2Async_StrikesRespectTimeframe()
|
||||
{
|
||||
_context.Events.Add(Event(EventType.StalledStrike));
|
||||
_context.Events.Add(Event(EventType.StalledStrike, timestamp: DateTimeOffset.UtcNow.AddHours(-100)));
|
||||
_context.Events.Add(Event(EventType.StalledStrike, timestamp: FixedNow.AddHours(-100)));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
StatsV2Response stats = await _service.GetStatsV2Async(24);
|
||||
@@ -210,7 +216,7 @@ public class StatsServiceV2Tests : IDisposable
|
||||
[Fact]
|
||||
public async Task GetStatsV2Async_AggregatesJobRunsWithinTheTimeframe()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset now = FixedNow;
|
||||
_context.JobRuns.Add(Run(JobType.QueueCleaner, JobRunStatus.Completed, now.AddHours(-2)));
|
||||
_context.JobRuns.Add(Run(JobType.QueueCleaner, JobRunStatus.Failed, now.AddHours(-1)));
|
||||
_context.JobRuns.Add(Run(JobType.MalwareBlocker, JobRunStatus.Completed, now.AddHours(-3)));
|
||||
@@ -234,7 +240,7 @@ public class StatsServiceV2Tests : IDisposable
|
||||
[Fact]
|
||||
public async Task GetStatsV2Async_JobsCarryTheNextScheduledRun()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset now = FixedNow;
|
||||
DateTimeOffset nextQueueCleanerRun = now.AddMinutes(5);
|
||||
DateTimeOffset nextSeekerRun = now.AddMinutes(30);
|
||||
|
||||
@@ -265,7 +271,7 @@ public class StatsServiceV2Tests : IDisposable
|
||||
{
|
||||
Guid clientId = Guid.NewGuid();
|
||||
Guid instanceId = Guid.NewGuid();
|
||||
DateTimeOffset checkedAt = DateTimeOffset.UtcNow.AddMinutes(-2);
|
||||
DateTimeOffset checkedAt = FixedNow.AddMinutes(-2);
|
||||
|
||||
_health.GetAllClientHealth().Returns(new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
@@ -350,7 +356,7 @@ public class StatsServiceV2Tests : IDisposable
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_BucketsHourlyForShortTimeframesAndDailyBeyond()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset now = FixedNow;
|
||||
_context.Events.Add(Event(EventType.StrikeReset, timestamp: now));
|
||||
_context.Events.Add(Event(EventType.StrikeReset, timestamp: now.AddHours(-2)));
|
||||
_context.Events.Add(Event(EventType.StrikeReset, timestamp: now.AddDays(-3)));
|
||||
@@ -369,7 +375,7 @@ public class StatsServiceV2Tests : IDisposable
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_MonthBucketsAreFirstOfMonth()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset now = FixedNow;
|
||||
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now));
|
||||
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-40)));
|
||||
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-75)));
|
||||
@@ -385,7 +391,7 @@ public class StatsServiceV2Tests : IDisposable
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_WeekBucketsStartOnMonday()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset now = FixedNow;
|
||||
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now));
|
||||
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-10)));
|
||||
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-20)));
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Clock control for handlers that wait on a <see cref="FakeTimeProvider"/>.
|
||||
/// </summary>
|
||||
public static class FakeTimeProviderExtensions
|
||||
{
|
||||
private static readonly TimeSpan Step = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
private const int MaxAdvances = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Awaits a running handler, advancing the clock past the delays it waits on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A handler registers its timer asynchronously, so a single advance can land before the timer
|
||||
/// exists and leave it waiting for a due time the clock has already passed. Advancing a few
|
||||
/// times covers that race, and the cap keeps the clock close enough for file age assertions.
|
||||
/// </remarks>
|
||||
/// <exception cref="TimeoutException">The handler did not finish after the last advance.</exception>
|
||||
public static async Task AdvanceUntilCompleted(this FakeTimeProvider timeProvider, Task execution)
|
||||
{
|
||||
for (int advance = 0; advance < MaxAdvances && !execution.IsCompleted; advance++)
|
||||
{
|
||||
timeProvider.Advance(Step);
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
await execution.WaitAsync(Timeout);
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -99,7 +99,7 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService
|
||||
{
|
||||
if (skippedClientIds.Contains(clientConfig.DownloadClientConfigId))
|
||||
{
|
||||
_logger.LogWarning("skip | torrents are unavailable or empty | {name}", clientConfig.DownloadClientConfig.Name);
|
||||
_logger.LogWarning("skip | torrents are unavailable | {name}", clientConfig.DownloadClientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -144,14 +144,13 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get torrents | {name}", downloadClient.Name);
|
||||
_logger.LogError(ex, "Failed to get torrents | {Name}", downloadClient.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (torrents.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No torrents found | {name}", downloadClient.Name);
|
||||
return false;
|
||||
_logger.LogWarning("No torrents reported | {Name}", downloadClient.Name);
|
||||
}
|
||||
|
||||
foreach (string claimedPath in await downloadService.GetClaimedPathsAsync(torrents))
|
||||
@@ -159,7 +158,7 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService
|
||||
claimedPaths.Add(claimedPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loaded {count} torrents | {name}", torrents.Count, downloadClient.Name);
|
||||
_logger.LogDebug("Loaded {Count} torrents | {Name}", torrents.Count, downloadClient.Name);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -1,6 +1,7 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
@@ -29,16 +30,20 @@ public partial class DelugeService
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
|
||||
{
|
||||
var downloads = await _client.GetStatusForAllTorrents();
|
||||
List<DownloadStatus>? downloads = await _client.GetStatusForAllTorrents();
|
||||
if (downloads is null)
|
||||
{
|
||||
return [];
|
||||
throw new DelugeClientException("Deluge returned no torrent status");
|
||||
}
|
||||
|
||||
return downloads
|
||||
List<ITorrentItemWrapper> torrents = downloads
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Select(ITorrentItemWrapper (x) => new DelugeItemWrapper(x))
|
||||
.ToList();
|
||||
|
||||
ThrowIfTorrentListCollapsed(downloads.Count, torrents.Count);
|
||||
|
||||
return torrents;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -86,6 +86,22 @@ public abstract class DownloadService : IDownloadService
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents);
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a torrent list that lost every row the client reported.
|
||||
/// </summary>
|
||||
/// <param name="reportedCount">Rows the client sent.</param>
|
||||
/// <param name="usableCount">Rows that survived parsing and filtering.</param>
|
||||
/// <exception cref="InvalidOperationException">Every reported row was unusable.</exception>
|
||||
protected void ThrowIfTorrentListCollapsed(int reportedCount, int usableCount)
|
||||
{
|
||||
if (reportedCount is 0 || usableCount > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"{_downloadClientConfig.Name} reported {reportedCount} torrents, none of them usable");
|
||||
}
|
||||
|
||||
protected async Task<IReadOnlyList<string>> BuildClaimedPathsAsync(
|
||||
IReadOnlyList<ITorrentItemWrapper> torrents,
|
||||
Func<ITorrentItemWrapper, Task<IReadOnlyCollection<string>>> resolveRelativeFilePaths)
|
||||
|
||||
+7
-3
@@ -44,16 +44,20 @@ public partial class QBitService
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
|
||||
{
|
||||
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery());
|
||||
IReadOnlyList<TorrentInfo>? torrentList = await _client.GetTorrentListAsync(new TorrentListQuery());
|
||||
if (torrentList is null)
|
||||
{
|
||||
return [];
|
||||
throw new InvalidOperationException("qBittorrent returned no torrent list");
|
||||
}
|
||||
|
||||
return torrentList
|
||||
List<ITorrentItemWrapper> torrents = torrentList
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Select(ITorrentItemWrapper (t) => new QBitItemWrapper(t, [], false))
|
||||
.ToList();
|
||||
|
||||
ThrowIfTorrentListCollapsed(torrentList.Count, torrents.Count);
|
||||
|
||||
return torrents;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
+12
-4
@@ -317,19 +317,27 @@ public sealed class RTorrentClient
|
||||
var array = value.Element("array");
|
||||
var data = array?.Element("data");
|
||||
|
||||
if (data == null) return result;
|
||||
if (data == null)
|
||||
{
|
||||
throw new RTorrentClientException("Invalid XML-RPC response: expected an array of torrents");
|
||||
}
|
||||
|
||||
foreach (var itemValue in data.Elements("value"))
|
||||
{
|
||||
var innerArray = itemValue.Element("array")?.Element("data");
|
||||
if (innerArray == null) continue;
|
||||
if (innerArray == null)
|
||||
{
|
||||
throw new RTorrentClientException("Invalid XML-RPC response: torrent row is not an array");
|
||||
}
|
||||
|
||||
var values = innerArray.Elements("value").Select(ParseSingleValue).ToArray();
|
||||
var torrent = CreateTorrentFromValues(values);
|
||||
if (torrent != null)
|
||||
if (torrent == null)
|
||||
{
|
||||
result.Add(torrent);
|
||||
throw new RTorrentClientException($"Invalid XML-RPC response: torrent row has {values.Length} of {TorrentFields.Length} fields");
|
||||
}
|
||||
|
||||
result.Add(torrent);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
+6
-2
@@ -24,12 +24,16 @@ public partial class RTorrentService
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
|
||||
{
|
||||
var downloads = await _client.GetAllTorrentsAsync();
|
||||
List<RTorrentTorrent> downloads = await _client.GetAllTorrentsAsync();
|
||||
|
||||
return downloads
|
||||
List<ITorrentItemWrapper> torrents = downloads
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Select(ITorrentItemWrapper (x) => new RTorrentItemWrapper(x))
|
||||
.ToList();
|
||||
|
||||
ThrowIfTorrentListCollapsed(downloads.Count, torrents.Count);
|
||||
|
||||
return torrents;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
+13
-4
@@ -24,11 +24,20 @@ public partial class TransmissionService
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
|
||||
{
|
||||
var result = await _client.TorrentGetAsync(Fields);
|
||||
return result?.Torrents
|
||||
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
TransmissionTorrents? result = await _client.TorrentGetAsync(Fields);
|
||||
if (result?.Torrents is null)
|
||||
{
|
||||
throw new InvalidOperationException("Transmission returned no torrent list");
|
||||
}
|
||||
|
||||
List<ITorrentItemWrapper> torrents = result.Torrents
|
||||
.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Select(ITorrentItemWrapper (x) => new TransmissionItemWrapper(x))
|
||||
.ToList() ?? [];
|
||||
.ToList();
|
||||
|
||||
ThrowIfTorrentListCollapsed(result.Torrents.Length, torrents.Count);
|
||||
|
||||
return torrents;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
public interface IUTorrentClientWrapper
|
||||
{
|
||||
Task<bool> LoginAsync();
|
||||
Task<bool> TestConnectionAsync();
|
||||
Task TestConnectionAsync();
|
||||
Task<List<UTorrentItem>> GetTorrentsAsync();
|
||||
Task<UTorrentItem?> GetTorrentAsync(string hash);
|
||||
Task<List<UTorrentFile>?> GetTorrentFilesAsync(string hash);
|
||||
|
||||
+3
-11
@@ -51,18 +51,10 @@ public sealed class UTorrentClient
|
||||
/// <summary>
|
||||
/// Tests the authentication and basic API connectivity
|
||||
/// </summary>
|
||||
/// <returns>True if authentication and basic API call works</returns>
|
||||
public async Task<bool> TestConnectionAsync()
|
||||
/// <exception cref="UTorrentException">Authentication failed or the response could not be read.</exception>
|
||||
public async Task TestConnectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var torrents = await GetTorrentsAsync();
|
||||
return true; // If we can get torrents, authentication is working
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
await GetTorrentsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public sealed class UTorrentClientWrapper : IUTorrentClientWrapper
|
||||
public Task<bool> LoginAsync()
|
||||
=> _client.LoginAsync();
|
||||
|
||||
public Task<bool> TestConnectionAsync()
|
||||
public Task TestConnectionAsync()
|
||||
=> _client.TestConnectionAsync();
|
||||
|
||||
public Task<List<UTorrentItem>> GetTorrentsAsync()
|
||||
|
||||
+33
-31
@@ -89,39 +89,41 @@ public class UTorrentResponseParser : IUTorrentResponseParser
|
||||
{
|
||||
foreach (JsonElement[] data in response.TorrentsRaw)
|
||||
{
|
||||
if (data is { Length: >= 27 })
|
||||
if (data is not { Length: >= 27 })
|
||||
{
|
||||
response.Torrents.Add(new UTorrentItem
|
||||
{
|
||||
Hash = AsString(data[0]),
|
||||
Status = AsInt32(data[1]),
|
||||
Name = AsString(data[2]),
|
||||
Size = AsInt64(data[3]),
|
||||
Progress = AsInt32(data[4]),
|
||||
Downloaded = AsInt64(data[5]),
|
||||
Uploaded = AsInt64(data[6]),
|
||||
RatioRaw = AsInt32(data[7]),
|
||||
UploadSpeed = AsInt32(data[8]),
|
||||
DownloadSpeed = AsInt32(data[9]),
|
||||
ETA = AsInt32(data[10]),
|
||||
Label = AsString(data[11]),
|
||||
PeersConnected = AsInt32(data[12]),
|
||||
PeersInSwarm = AsInt32(data[13]),
|
||||
SeedsConnected = AsInt32(data[14]),
|
||||
SeedsInSwarm = AsInt32(data[15]),
|
||||
Availability = AsInt32(data[16]),
|
||||
QueueOrder = AsInt32(data[17]),
|
||||
Remaining = AsInt64(data[18]),
|
||||
DownloadUrl = AsString(data[19]),
|
||||
RssFeedUrl = AsString(data[20]),
|
||||
StatusMessage = AsString(data[21]),
|
||||
StreamId = AsString(data[22]),
|
||||
DateAdded = AsInt64(data[23]),
|
||||
DateCompleted = AsInt64(data[24]),
|
||||
AppUpdateUrl = AsString(data[25]),
|
||||
SavePath = AsString(data[26])
|
||||
});
|
||||
throw new UTorrentParsingException($"Torrent row has {data?.Length ?? 0} of the 27 expected fields", json);
|
||||
}
|
||||
|
||||
response.Torrents.Add(new UTorrentItem
|
||||
{
|
||||
Hash = AsString(data[0]),
|
||||
Status = AsInt32(data[1]),
|
||||
Name = AsString(data[2]),
|
||||
Size = AsInt64(data[3]),
|
||||
Progress = AsInt32(data[4]),
|
||||
Downloaded = AsInt64(data[5]),
|
||||
Uploaded = AsInt64(data[6]),
|
||||
RatioRaw = AsInt32(data[7]),
|
||||
UploadSpeed = AsInt32(data[8]),
|
||||
DownloadSpeed = AsInt32(data[9]),
|
||||
ETA = AsInt32(data[10]),
|
||||
Label = AsString(data[11]),
|
||||
PeersConnected = AsInt32(data[12]),
|
||||
PeersInSwarm = AsInt32(data[13]),
|
||||
SeedsConnected = AsInt32(data[14]),
|
||||
SeedsInSwarm = AsInt32(data[15]),
|
||||
Availability = AsInt32(data[16]),
|
||||
QueueOrder = AsInt32(data[17]),
|
||||
Remaining = AsInt64(data[18]),
|
||||
DownloadUrl = AsString(data[19]),
|
||||
RssFeedUrl = AsString(data[20]),
|
||||
StatusMessage = AsString(data[21]),
|
||||
StreamId = AsString(data[22]),
|
||||
DateAdded = AsInt64(data[23]),
|
||||
DateCompleted = AsInt64(data[24]),
|
||||
AppUpdateUrl = AsString(data[25]),
|
||||
SavePath = AsString(data[26])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-6
@@ -123,12 +123,8 @@ public partial class UTorrentService : DownloadService, IUTorrentService
|
||||
await _client.LoginAsync();
|
||||
|
||||
// Test API connectivity with a simple request
|
||||
var connectionOk = await _client.TestConnectionAsync();
|
||||
if (!connectionOk)
|
||||
{
|
||||
throw new InvalidOperationException("API connection test failed");
|
||||
}
|
||||
|
||||
await _client.TestConnectionAsync();
|
||||
|
||||
_logger.LogDebug("Health check: Successfully connected to µTorrent client {clientId}", _downloadClientConfig.Id);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
+6
-2
@@ -35,12 +35,16 @@ public partial class UTorrentService
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
|
||||
{
|
||||
var torrents = await _client.GetTorrentsAsync();
|
||||
List<UTorrentItem> reported = await _client.GetTorrentsAsync();
|
||||
|
||||
return torrents
|
||||
List<ITorrentItemWrapper> torrents = reported
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Select(ITorrentItemWrapper (x) => new UTorrentItemWrapper(x, new UTorrentProperties()))
|
||||
.ToList();
|
||||
|
||||
ThrowIfTorrentListCollapsed(reported.Count, torrents.Count);
|
||||
|
||||
return torrents;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -19,19 +19,22 @@ public class StatsService : IStatsService
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly IDatabaseProvider _databaseProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public StatsService(
|
||||
ILogger<StatsService> logger,
|
||||
EventsContext eventsContext,
|
||||
IHealthCheckService healthCheckService,
|
||||
IJobManagementService jobManagementService,
|
||||
IDatabaseProvider databaseProvider)
|
||||
IDatabaseProvider databaseProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_eventsContext = eventsContext;
|
||||
_healthCheckService = healthCheckService;
|
||||
_jobManagementService = jobManagementService;
|
||||
_databaseProvider = databaseProvider;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<EventType, StrikeType> StrikeEventToType = new()
|
||||
@@ -55,7 +58,7 @@ public class StatsService : IStatsService
|
||||
/// <inheritdoc />
|
||||
public async Task<StatsV2Response> GetStatsV2Async(int hours, bool includeDryRun = false)
|
||||
{
|
||||
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddHours(-hours);
|
||||
DateTimeOffset cutoff = _timeProvider.GetUtcNow().AddHours(-hours);
|
||||
|
||||
Dictionary<string, int> byType = await MergedCountsAsync(cutoff, e => e.EventType, includeDryRun);
|
||||
Dictionary<string, int> bySeverity = await MergedCountsAsync(cutoff, e => e.Severity, includeDryRun);
|
||||
@@ -98,14 +101,14 @@ public class StatsService : IStatsService
|
||||
Jobs = await GetJobV2StatsAsync(cutoff),
|
||||
Health = GetHealthStats(),
|
||||
TimeframeHours = hours,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<TimelineBucketDto>> GetTimelineAsync(string metric, int hours, TimelineBucketSize? bucket = null, bool includeDryRun = false)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
DateTimeOffset cutoff = now.AddHours(-hours);
|
||||
TimelineBucketSize size = bucket ?? TimelineBucketing.DefaultFor(hours);
|
||||
|
||||
|
||||
@@ -45,10 +45,8 @@ const HOST_ORPHANED_DIR = join(HOST_DOWNLOADS, SLUG, 'orphaned');
|
||||
const APP_SCAN_DIR = `${APP_DOWNLOADS}/${SLUG}`;
|
||||
const APP_ORPHANED_DIR = `${APP_DOWNLOADS}/${SLUG}/orphaned`;
|
||||
|
||||
// The cleaner refuses to scan if a download client reports 0 torrents (to
|
||||
// avoid moving real downloads when the client is empty or unreachable). The
|
||||
// suite needs at least one torrent registered in qBit; we park a decoy
|
||||
// outside the scan dir so it never claims a test file.
|
||||
// A decoy torrent parked outside the scan dir, so the client is never empty
|
||||
// while these knobs are under test. It claims no test file.
|
||||
const HOST_DECOY_PARENT = join(HOST_DOWNLOADS, 'qbittorrent');
|
||||
const CLIENT_DECOY_PARENT = '/downloads';
|
||||
const DECOY_NAME = '__cleanuparr_decoy__';
|
||||
@@ -125,8 +123,8 @@ test.describe.serial('Orphaned files cleanup — behaviors', () => {
|
||||
await driver.ready();
|
||||
await driver.clearAllTorrents();
|
||||
|
||||
// Seed the decoy torrent. After the orphaned-files fix, an empty client
|
||||
// makes the cleaner bail; the decoy gives it something to consider.
|
||||
// Seed the decoy torrent.
|
||||
// An empty client is covered by orphaned-files-empty-client.spec.ts.
|
||||
mkdirShared(HOST_DECOY_PARENT);
|
||||
const decoy = buildFolderTorrent(HOST_DECOY_PARENT, DECOY_NAME);
|
||||
await driver.addTorrent({
|
||||
@@ -164,9 +162,8 @@ test.describe.serial('Orphaned files cleanup — behaviors', () => {
|
||||
// Reset filesystem state before each scenario.
|
||||
resetDirectory(HOST_SCAN_DIR);
|
||||
mkdirShared(HOST_ORPHANED_DIR);
|
||||
// The decoy torrent stays registered between tests so the cleaner has at
|
||||
// least one torrent visible; its save path is outside HOST_SCAN_DIR, so
|
||||
// every entry created here is unclaimed and treated as orphan.
|
||||
// The decoy stays registered between tests.
|
||||
// Its save path sits outside HOST_SCAN_DIR, so every entry here is unclaimed.
|
||||
});
|
||||
|
||||
const configureOrphanedFiles = async (
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { existsSync, readdirSync, statSync, utimesSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
loginAndGetToken,
|
||||
createDownloadClient,
|
||||
listDownloadClients,
|
||||
deleteDownloadClient,
|
||||
updateDownloadCleanerConfig,
|
||||
getDownloadCleanerConfig,
|
||||
updateOrphanedFilesConfig,
|
||||
triggerJob,
|
||||
OrphanedFilesConfigRequest,
|
||||
} from '../helpers/app-api';
|
||||
import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent';
|
||||
import { resetDirectory } from '../helpers/torrent-fixtures';
|
||||
import { mkdirShared, writeFileShared } from '../helpers/shared-volume';
|
||||
|
||||
/**
|
||||
* Regression guard for issue #746.
|
||||
*
|
||||
* A download client holding no torrents claims no paths, so every entry in
|
||||
* its scan directories is orphaned. The cleaner used to read an empty torrent
|
||||
* list as "do not trust this client" and skip the scan and the purge, which
|
||||
* stranded orphans on disk until the user added a torrent.
|
||||
*
|
||||
* A client whose list call throws is the opposite case and still bails. See
|
||||
* `orphaned-files-unreachable-client.spec.ts`.
|
||||
*
|
||||
* `utimesSync` backdates mtime for the purge path, which reads only
|
||||
* `GetLastWriteTimeUtc`. It cannot fake the move path's MinFileAgeHours
|
||||
* check, which takes `MAX(lastWrite, created)`, and Linux birthtime resists
|
||||
* portable backdating. Unit tests cover that combination.
|
||||
*/
|
||||
|
||||
const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads');
|
||||
const APP_DOWNLOADS = '/e2e-downloads';
|
||||
const SLUG = 'qbittorrent-empty';
|
||||
const HOST_SCAN_DIR = join(HOST_DOWNLOADS, SLUG);
|
||||
const HOST_ORPHANED_DIR = join(HOST_DOWNLOADS, SLUG, 'orphaned');
|
||||
const APP_SCAN_DIR = `${APP_DOWNLOADS}/${SLUG}`;
|
||||
const APP_ORPHANED_DIR = `${APP_DOWNLOADS}/${SLUG}/orphaned`;
|
||||
|
||||
// The unreachable sibling in the last test needs its own directory pair.
|
||||
// Validation rejects a scan directory that overlaps another client's.
|
||||
const SIBLING_SLUG = 'qbittorrent-noconn';
|
||||
const HOST_SIBLING_SCAN_DIR = join(HOST_DOWNLOADS, SIBLING_SLUG);
|
||||
const HOST_SIBLING_ORPHANED_DIR = join(HOST_DOWNLOADS, SIBLING_SLUG, 'orphaned');
|
||||
const APP_SIBLING_SCAN_DIR = `${APP_DOWNLOADS}/${SIBLING_SLUG}`;
|
||||
const APP_SIBLING_ORPHANED_DIR = `${APP_DOWNLOADS}/${SIBLING_SLUG}/orphaned`;
|
||||
|
||||
function backdateRecursive(path: string, hoursAgo: number): void {
|
||||
const t = (Date.now() - hoursAgo * 3600_000) / 1000;
|
||||
const visit = (p: string) => {
|
||||
utimesSync(p, t, t);
|
||||
if (statSync(p).isDirectory()) {
|
||||
for (const e of readdirSync(p)) visit(join(p, e));
|
||||
}
|
||||
};
|
||||
visit(path);
|
||||
}
|
||||
|
||||
function writeOrphanFile(dir: string, name: string, content = 'orphan'): string {
|
||||
mkdirShared(dir);
|
||||
const path = join(dir, name);
|
||||
writeFileShared(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for: ${label}`);
|
||||
}
|
||||
|
||||
async function triggerAndSettle(token: string): Promise<void> {
|
||||
const res = await triggerJob(token, 'DownloadCleaner');
|
||||
expect(res.ok, `triggerJob: ${res.status}`).toBe(true);
|
||||
// The cleaner walks the directories on a worker thread.
|
||||
// Positive assertions poll after this window.
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
|
||||
test.describe.serial('Orphaned files cleanup — client with no torrents', () => {
|
||||
const driver = new QBittorrentDriver();
|
||||
let token: string;
|
||||
let clientId: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
token = await loginAndGetToken();
|
||||
|
||||
// Clear leftover clients from other specs.
|
||||
const existing = await listDownloadClients(token);
|
||||
for (const client of existing) {
|
||||
await deleteDownloadClient(token, client.id);
|
||||
}
|
||||
|
||||
// The job runs on demand, so the schedule does not matter.
|
||||
const dcCurrent = await (await getDownloadCleanerConfig(token)).json();
|
||||
await updateDownloadCleanerConfig(token, {
|
||||
enabled: true,
|
||||
cronExpression: dcCurrent.cronExpression || '0 0 * * * ?',
|
||||
useAdvancedScheduling: dcCurrent.useAdvancedScheduling ?? false,
|
||||
ignoredDownloads: [],
|
||||
});
|
||||
|
||||
mkdirShared(HOST_DOWNLOADS);
|
||||
|
||||
// No decoy torrent here. An empty client is the subject of this spec.
|
||||
await driver.ready();
|
||||
await driver.clearAllTorrents();
|
||||
|
||||
const createRes = await createDownloadClient(token, {
|
||||
enabled: true,
|
||||
name: 'qBittorrent empty',
|
||||
typeName: driver.typeName,
|
||||
type: 'Torrent',
|
||||
host: driver.cleanuparrHost,
|
||||
username: driver.username ?? '',
|
||||
password: driver.password ?? '',
|
||||
downloadDirectorySource: '/downloads',
|
||||
downloadDirectoryTarget: APP_SCAN_DIR,
|
||||
});
|
||||
expect(createRes.ok, `createDownloadClient: ${createRes.status}`).toBe(true);
|
||||
const created = await createRes.json();
|
||||
clientId = created.id;
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
resetDirectory(HOST_SCAN_DIR);
|
||||
mkdirShared(HOST_ORPHANED_DIR);
|
||||
await driver.clearAllTorrents();
|
||||
|
||||
// A retry can leave the last test's sibling client behind.
|
||||
const existing = await listDownloadClients(token);
|
||||
for (const client of existing) {
|
||||
if (client.id !== clientId) {
|
||||
await deleteDownloadClient(token, client.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const configureOrphanedFiles = async (
|
||||
downloadClientId: string,
|
||||
overrides: Partial<OrphanedFilesConfigRequest> = {},
|
||||
): Promise<void> => {
|
||||
const config: OrphanedFilesConfigRequest = {
|
||||
enabled: true,
|
||||
scanDirectories: [APP_SCAN_DIR],
|
||||
orphanedDirectory: APP_ORPHANED_DIR,
|
||||
excludePatterns: [],
|
||||
minFileAgeHours: 0,
|
||||
purgeAfterHours: null,
|
||||
...overrides,
|
||||
};
|
||||
const res = await updateOrphanedFilesConfig(token, downloadClientId, config);
|
||||
expect(res.ok, `updateOrphanedFilesConfig: ${res.status}`).toBe(true);
|
||||
};
|
||||
|
||||
test('Empty client → the orphan is moved to the orphaned directory', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const orphan = writeOrphanFile(HOST_SCAN_DIR, 'orphan.mkv');
|
||||
await configureOrphanedFiles(clientId);
|
||||
|
||||
await triggerAndSettle(token);
|
||||
await waitForCondition(
|
||||
() => existsSync(join(HOST_ORPHANED_DIR, 'orphan.mkv')),
|
||||
15_000,
|
||||
'orphan.mkv moved into the orphaned directory',
|
||||
);
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
});
|
||||
|
||||
test('Empty client → aged entries in the orphaned directory are purged', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const aged = writeOrphanFile(HOST_ORPHANED_DIR, 'aged.bin');
|
||||
backdateRecursive(aged, 25);
|
||||
await configureOrphanedFiles(clientId, { purgeAfterHours: 24 });
|
||||
|
||||
await triggerAndSettle(token);
|
||||
await waitForCondition(() => !existsSync(aged), 15_000, `purge of ${aged}`);
|
||||
});
|
||||
|
||||
test('Empty client → MinFileAgeHours still protects a fresh entry', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const fresh = writeOrphanFile(HOST_SCAN_DIR, 'fresh.bin');
|
||||
await configureOrphanedFiles(clientId, { minFileAgeHours: 1 });
|
||||
|
||||
const res = await triggerJob(token, 'DownloadCleaner');
|
||||
expect(res.ok, `triggerJob: ${res.status}`).toBe(true);
|
||||
// A negative assertion needs a window well past the real move time.
|
||||
// A short wait lets a broken build race the check and pass.
|
||||
await new Promise((r) => setTimeout(r, 20_000));
|
||||
|
||||
expect(existsSync(fresh)).toBe(true);
|
||||
expect(readdirSync(HOST_ORPHANED_DIR).length).toBe(0);
|
||||
});
|
||||
|
||||
test('Empty client is scanned while an unreachable sibling is left alone', async () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
resetDirectory(HOST_SIBLING_SCAN_DIR);
|
||||
mkdirShared(HOST_SIBLING_ORPHANED_DIR);
|
||||
|
||||
const orphan = writeOrphanFile(HOST_SCAN_DIR, 'orphan.mkv');
|
||||
const realDownload = writeOrphanFile(HOST_SIBLING_SCAN_DIR, 'real-download.mkv', 'real');
|
||||
|
||||
await configureOrphanedFiles(clientId);
|
||||
|
||||
const createRes = await createDownloadClient(token, {
|
||||
enabled: true,
|
||||
name: 'qBittorrent unreachable sibling',
|
||||
typeName: 'qBittorrent',
|
||||
type: 'Torrent',
|
||||
// Nothing listens on port 1, so the qBit client fails to connect.
|
||||
host: 'http://127.0.0.1:1',
|
||||
username: 'admin',
|
||||
password: 'adminadmin',
|
||||
downloadDirectorySource: '/downloads',
|
||||
downloadDirectoryTarget: APP_SIBLING_SCAN_DIR,
|
||||
});
|
||||
expect(createRes.ok, `createDownloadClient: ${createRes.status}`).toBe(true);
|
||||
const sibling = await createRes.json();
|
||||
|
||||
await configureOrphanedFiles(sibling.id, {
|
||||
scanDirectories: [APP_SIBLING_SCAN_DIR],
|
||||
orphanedDirectory: APP_SIBLING_ORPHANED_DIR,
|
||||
});
|
||||
|
||||
await triggerAndSettle(token);
|
||||
await waitForCondition(
|
||||
() => existsSync(join(HOST_ORPHANED_DIR, 'orphan.mkv')),
|
||||
15_000,
|
||||
'the empty client orphan moved',
|
||||
);
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
|
||||
expect(existsSync(realDownload)).toBe(true);
|
||||
expect(readdirSync(HOST_SIBLING_ORPHANED_DIR).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -11,26 +11,24 @@ import {
|
||||
updateOrphanedFilesConfig,
|
||||
triggerJob,
|
||||
} from '../helpers/app-api';
|
||||
import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent';
|
||||
import { resetDirectory } from '../helpers/torrent-fixtures';
|
||||
import { mkdirShared, writeFileShared } from '../helpers/shared-volume';
|
||||
|
||||
/**
|
||||
* Regression guard for the orphaned-files cleanup safety bail.
|
||||
*
|
||||
* The cleaner refuses to move anything for a download client when it cannot
|
||||
* trust the client's torrent list — either because the call threw (client
|
||||
* unreachable / authentication broken) or because the client reported 0
|
||||
* torrents. Without this guard, an empty/erroring client makes every file in
|
||||
* the scan directory look orphaned and real downloads get moved.
|
||||
* The cleaner moves and purges nothing for a download client whose torrent
|
||||
* list it could not retrieve. Drop that guard and an erroring client makes
|
||||
* every scan-directory entry look unclaimed, so the cleaner moves real
|
||||
* downloads out.
|
||||
*
|
||||
* Two scenarios, both assert "files remain in the scan dir":
|
||||
* This spec points the client at a port nothing listens on. That hits the
|
||||
* catch path in `TryAddClaimedPathsAsync`, or the LoginAsync skip above it.
|
||||
* Both produce the same outcome for the user.
|
||||
*
|
||||
* 1. Unreachable host — client registered against a port nothing listens
|
||||
* on. Exercises the catch path (or upstream LoginAsync skip — both lead
|
||||
* to the same user-visible outcome).
|
||||
* 2. Reachable client with 0 torrents — qBittorrent up but empty.
|
||||
* Exercises the explicit zero-torrents bail in `TryAddClaimedPathsAsync`.
|
||||
* A reachable client reporting 0 torrents is the opposite case: it claims
|
||||
* nothing, so the cleaner does scan its directories. See
|
||||
* `orphaned-files-empty-client.spec.ts`.
|
||||
*/
|
||||
|
||||
const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads');
|
||||
@@ -79,9 +77,8 @@ test.describe.serial('Orphaned files cleanup — refuses to scan when client dat
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Each scenario starts with a fresh scan dir and a single fake real
|
||||
// download. If the scanner runs incorrectly the file gets moved into
|
||||
// HOST_ORPHANED_DIR — that's the regression we're guarding against.
|
||||
// A fresh scan dir holding one fake real download.
|
||||
// A scan that runs anyway lands it in HOST_ORPHANED_DIR.
|
||||
resetDirectory(HOST_SCAN_DIR);
|
||||
mkdirShared(HOST_ORPHANED_DIR);
|
||||
|
||||
@@ -102,8 +99,7 @@ test.describe.serial('Orphaned files cleanup — refuses to scan when client dat
|
||||
name: 'qBittorrent unreachable',
|
||||
typeName: 'qBittorrent',
|
||||
type: 'Torrent',
|
||||
// Port 1 — nothing listens here. Cleanuparr's qBit client will fail to
|
||||
// connect when the cleaner runs.
|
||||
// Nothing listens on port 1, so the qBit client fails to connect.
|
||||
host: 'http://127.0.0.1:1',
|
||||
username: 'admin',
|
||||
password: 'adminadmin',
|
||||
@@ -127,42 +123,4 @@ test.describe.serial('Orphaned files cleanup — refuses to scan when client dat
|
||||
expect(existsSync(join(HOST_ORPHANED_DIR, 'real-download.mkv'))).toBe(false);
|
||||
expect(readdirSync(HOST_ORPHANED_DIR).length).toBe(0);
|
||||
});
|
||||
|
||||
test('Reachable client reporting 0 torrents → files in the scan dir are not moved', async () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const driver = new QBittorrentDriver();
|
||||
await driver.ready();
|
||||
await driver.clearAllTorrents();
|
||||
|
||||
const realDownload = writeFile(HOST_SCAN_DIR, 'real-download.mkv');
|
||||
|
||||
const createRes = await createDownloadClient(token, {
|
||||
enabled: true,
|
||||
name: 'qBittorrent empty',
|
||||
typeName: driver.typeName,
|
||||
type: 'Torrent',
|
||||
host: driver.cleanuparrHost,
|
||||
username: driver.username ?? '',
|
||||
password: driver.password ?? '',
|
||||
downloadDirectorySource: '/downloads',
|
||||
downloadDirectoryTarget: APP_SCAN_DIR,
|
||||
});
|
||||
expect(createRes.ok, `createDownloadClient: ${createRes.status}`).toBe(true);
|
||||
const created = await createRes.json();
|
||||
|
||||
const ofcRes = await updateOrphanedFilesConfig(token, created.id, {
|
||||
enabled: true,
|
||||
scanDirectories: [APP_SCAN_DIR],
|
||||
orphanedDirectory: APP_ORPHANED_DIR,
|
||||
minFileAgeHours: 0,
|
||||
});
|
||||
expect(ofcRes.ok, `updateOrphanedFilesConfig: ${ofcRes.status}`).toBe(true);
|
||||
|
||||
await triggerAndSettle(token);
|
||||
|
||||
expect(existsSync(realDownload)).toBe(true);
|
||||
expect(existsSync(join(HOST_ORPHANED_DIR, 'real-download.mkv'))).toBe(false);
|
||||
expect(readdirSync(HOST_ORPHANED_DIR).length).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user