Compare commits

..
24 changed files with 1212 additions and 194 deletions

No files matched your search

@@ -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
}
@@ -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]
@@ -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()
{
@@ -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)));
@@ -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);
}
}
@@ -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;
}
@@ -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)
@@ -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/>
@@ -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;
@@ -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/>
@@ -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/>
@@ -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);
@@ -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>
@@ -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()
@@ -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])
});
}
}
@@ -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();
@@ -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);
});
});