mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-11-02 12:25:07 -05:00
578 lines
25 KiB
C#
578 lines
25 KiB
C#
using Cleanuparr.Infrastructure.Features.LazyLibrarian;
|
|
using Cleanuparr.Domain.Entities;
|
|
using Cleanuparr.Domain.Entities.Arr.Queue;
|
|
using Cleanuparr.Domain.Entities.HealthCheck;
|
|
using Cleanuparr.Domain.Enums;
|
|
using Cleanuparr.Infrastructure.Events.Interfaces;
|
|
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.Features.Jobs.TestHelpers;
|
|
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
|
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
|
using Cleanuparr.Infrastructure.Features.Jobs;
|
|
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
|
using Cleanuparr.Infrastructure.Http;
|
|
using Cleanuparr.Infrastructure.Interceptors;
|
|
using Cleanuparr.Infrastructure.Services;
|
|
using Cleanuparr.Infrastructure.Services.Interfaces;
|
|
using Cleanuparr.Persistence.Models.Configuration;
|
|
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
|
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
|
using Cleanuparr.Persistence.Models.Events;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
|
|
|
|
[Collection(IntegrationTestCollection.Name)]
|
|
public class DownloadCleanerIntegrationTests : IDisposable
|
|
{
|
|
private readonly IntegrationTestFixture _fixture;
|
|
|
|
public DownloadCleanerIntegrationTests(IntegrationTestFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
_fixture.Reset();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Striker.RecurringHashes.Clear();
|
|
}
|
|
|
|
private DownloadCleaner CreateSut()
|
|
{
|
|
return new DownloadCleaner(
|
|
Substitute.For<ILogger<DownloadCleaner>>(),
|
|
_fixture.DataContext,
|
|
_fixture.Cache,
|
|
_fixture.RemovalQueue.Writer,
|
|
_fixture.ArrClientFactory,
|
|
_fixture.ArrQueueReader,
|
|
_fixture.DownloadServiceFactory,
|
|
_fixture.EventPublisher,
|
|
_fixture.TimeProvider,
|
|
_fixture.SeedingRulesService,
|
|
_fixture.UnlinkedService,
|
|
_fixture.DeadTorrentService,
|
|
_fixture.OrphanedFilesService,
|
|
_fixture.DryRunInterceptor,
|
|
_fixture.ForceImportService,
|
|
_fixture.LazyLibrarianService);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a mock download service that uses the actual DB config (so seeding rules match by ID).
|
|
/// </summary>
|
|
private static IMockDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig)
|
|
{
|
|
var mock = Substitute.For<IMockDownloadService>();
|
|
mock.ClientConfig.Returns(dbConfig);
|
|
mock.LoginAsync().Returns(Task.CompletedTask);
|
|
return mock;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ArrManagedDownloads_AreExcludedFromCleanup()
|
|
{
|
|
// Arrange
|
|
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
|
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
|
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
|
|
|
string arrManagedHash = "arr_managed_hash_123";
|
|
string orphanedHash = "orphaned_hash_456";
|
|
|
|
var arrManagedDownload = CreateMockTorrentItem(arrManagedHash, "Managed.Show.S01E01");
|
|
var orphanedDownload = CreateMockTorrentItem(orphanedHash, "Orphaned.Movie.2024");
|
|
|
|
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
|
|
mockDownloadService.GetSeedingDownloads()
|
|
.Returns([arrManagedDownload, orphanedDownload]);
|
|
|
|
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
|
.Returns(mockDownloadService);
|
|
|
|
// Setup arr queue reader to return the arr-managed hash
|
|
var queueRecord = new QueueRecord
|
|
{
|
|
Id = 1,
|
|
Title = "Managed.Show.S01E01",
|
|
Protocol = "torrent",
|
|
DownloadId = arrManagedHash
|
|
};
|
|
_fixture.SetupArrQueueReader(queueRecord);
|
|
|
|
var sut = CreateSut();
|
|
|
|
// Act - advance time past the 10s delay
|
|
var executeTask = sut.ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
|
|
|
// Assert: Only the orphaned download should be passed to filter/clean
|
|
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
|
|
Arg.Is<List<ITorrentItemWrapper>>(list =>
|
|
list.Count == 1 && list[0].DownloadId == orphanedHash),
|
|
Arg.Any<List<ISeedingRule>>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IgnoredDownloads_AreExcludedFromCleanup()
|
|
{
|
|
// Arrange
|
|
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
|
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
|
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
|
|
|
// Add a download name to the ignored list
|
|
var generalConfig = await _fixture.DataContext.GeneralConfigs.FirstAsync();
|
|
generalConfig.IgnoredDownloads.Add("ignored_download");
|
|
await _fixture.DataContext.SaveChangesAsync();
|
|
|
|
var ignoredDownload = CreateMockTorrentItem("some_hash", "ignored_download");
|
|
var normalDownload = CreateMockTorrentItem("normal_hash", "Normal.Movie.2024");
|
|
|
|
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
|
|
mockDownloadService.GetSeedingDownloads()
|
|
.Returns([ignoredDownload, normalDownload]);
|
|
|
|
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
|
.Returns(mockDownloadService);
|
|
|
|
// No arr-managed downloads
|
|
_fixture.ArrQueueReader.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
|
|
.Returns([]);
|
|
|
|
var sut = CreateSut();
|
|
|
|
// Act
|
|
var executeTask = sut.ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
|
|
|
// Assert: Only the non-ignored download should be processed
|
|
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
|
|
Arg.Is<List<ITorrentItemWrapper>>(list =>
|
|
list.Count == 1 && list[0].DownloadId == "normal_hash"),
|
|
Arg.Any<List<ISeedingRule>>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoDownloadClients_ExitsEarly()
|
|
{
|
|
// Arrange: No download clients configured (default seed has none)
|
|
var sut = CreateSut();
|
|
|
|
// Act
|
|
await sut.ExecuteAsync();
|
|
|
|
// Assert: No download service interactions, no events
|
|
_fixture.DownloadServiceFactory.DidNotReceive()
|
|
.GetDownloadService(Arg.Any<DownloadClientConfig>());
|
|
var events = await _fixture.EventsContext.Events.ToListAsync();
|
|
events.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleanedDownload_PublishesDownloadCleanedEvent()
|
|
{
|
|
// Arrange
|
|
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
|
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
|
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
|
|
|
var torrent = CreateMockTorrentItem("cleaned_hash_abc", "Completed.Movie.2024");
|
|
|
|
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
|
|
mockDownloadService.GetSeedingDownloads().Returns([torrent]);
|
|
mockDownloadService.FilterDownloadsToBeCleanedAsync(
|
|
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<List<ISeedingRule>>())
|
|
.Returns(ci => ci.Arg<List<ITorrentItemWrapper>>());
|
|
|
|
// Configure CleanDownloadsAsync to simulate what real DownloadService does:
|
|
// set ContextProvider keys and call real EventPublisher
|
|
mockDownloadService.CleanDownloadsAsync(
|
|
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<List<ISeedingRule>>())
|
|
.Returns(async ci =>
|
|
{
|
|
ContextProvider.Set(ContextProvider.Keys.ItemName, "Completed.Movie.2024");
|
|
ContextProvider.Set(ContextProvider.Keys.Hash, "cleaned_hash_abc");
|
|
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, downloadClient.ExternalOrInternalUrl);
|
|
ContextProvider.Set(ContextProvider.Keys.DownloadClientId, downloadClient.Id);
|
|
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, downloadClient.TypeName);
|
|
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, downloadClient.Name);
|
|
await _fixture.EventPublisher.PublishDownloadCleaned(
|
|
1.5, TimeSpan.FromHours(24), "completed", CleanReason.MaxRatioReached);
|
|
});
|
|
|
|
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
|
.Returns(mockDownloadService);
|
|
|
|
// No arr-managed downloads
|
|
_fixture.ArrQueueReader.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
|
|
.Returns([]);
|
|
|
|
var sut = CreateSut();
|
|
|
|
// Act
|
|
var executeTask = sut.ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
|
|
|
// Assert: Full DownloadCleaned event property verification
|
|
var events = await _fixture.EventsContext.Events.ToListAsync();
|
|
events.Count.ShouldBe(1);
|
|
|
|
var cleanedEvent = events.First(e => e.EventType == EventType.DownloadCleaned);
|
|
cleanedEvent.Message.ShouldBe("Cleaned item from download client with reason: MaxRatioReached");
|
|
cleanedEvent.Severity.ShouldBe(EventSeverity.Important);
|
|
cleanedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
|
cleanedEvent.ArrInstanceId.ShouldBeNull();
|
|
cleanedEvent.DownloadClientId.ShouldBe(downloadClient.Id);
|
|
cleanedEvent.IsDryRun.ShouldBe(false);
|
|
cleanedEvent.StrikeId.ShouldBeNull();
|
|
cleanedEvent.TrackingId.ShouldBeNull();
|
|
cleanedEvent.SearchStatus.ShouldBeNull();
|
|
cleanedEvent.CompletedAt.ShouldBeNull();
|
|
cleanedEvent.CycleId.ShouldBeNull();
|
|
cleanedEvent.ItemTitle.ShouldBe("Completed.Movie.2024");
|
|
cleanedEvent.ItemHash.ShouldBe("cleaned_hash_abc");
|
|
cleanedEvent.CleanedCategory.ShouldBe("completed");
|
|
cleanedEvent.SeedRatio.ShouldBe(1.5);
|
|
cleanedEvent.SeedingTimeHours.ShouldBe(24.0);
|
|
cleanedEvent.CleanReason.ShouldBe(CleanReason.MaxRatioReached);
|
|
|
|
// Assert: Notification sent
|
|
await _fixture.NotificationPublisher.Received(1)
|
|
.NotifyDownloadCleaned(1.5, TimeSpan.FromHours(24), "completed", CleanReason.MaxRatioReached);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnlinkedDownload_PublishesCategoryChangedEvent()
|
|
{
|
|
// Arrange
|
|
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
|
var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
|
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext,
|
|
enabled: true, targetCategory: "unlinked", categories: ["completed"]);
|
|
|
|
var torrent = CreateMockTorrentItem("unlinked_hash_xyz", "NoLinks.Movie.2024", category: "completed");
|
|
|
|
var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient);
|
|
mockDownloadService.GetSeedingDownloads().Returns([torrent]);
|
|
mockDownloadService.FilterDownloadsToChangeCategoryAsync(
|
|
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
|
|
.Returns(ci => ci.Arg<List<ITorrentItemWrapper>>());
|
|
|
|
// Configure ChangeCategoryForNoHardLinksAsync to simulate what real DownloadService does
|
|
mockDownloadService.ChangeCategoryForNoHardLinksAsync(
|
|
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
|
|
.Returns(async ci =>
|
|
{
|
|
ContextProvider.Set(ContextProvider.Keys.ItemName, "NoLinks.Movie.2024");
|
|
ContextProvider.Set(ContextProvider.Keys.Hash, "unlinked_hash_xyz");
|
|
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, downloadClient.ExternalOrInternalUrl);
|
|
ContextProvider.Set(ContextProvider.Keys.DownloadClientId, downloadClient.Id);
|
|
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, downloadClient.TypeName);
|
|
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, downloadClient.Name);
|
|
await _fixture.EventPublisher.PublishCategoryChanged("completed", "unlinked");
|
|
});
|
|
|
|
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
|
.Returns(mockDownloadService);
|
|
|
|
// No arr-managed downloads
|
|
_fixture.ArrQueueReader.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
|
|
.Returns([]);
|
|
|
|
var sut = CreateSut();
|
|
|
|
// Act
|
|
var executeTask = sut.ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
|
|
|
// Assert: Full CategoryChanged event property verification
|
|
var events = await _fixture.EventsContext.Events.ToListAsync();
|
|
events.Count.ShouldBe(1);
|
|
|
|
var categoryEvent = events.First(e => e.EventType == EventType.CategoryChanged);
|
|
categoryEvent.Message.ShouldBe("Category changed from 'completed' to 'unlinked'");
|
|
categoryEvent.Severity.ShouldBe(EventSeverity.Information);
|
|
categoryEvent.JobRunId.ShouldBe(_fixture.JobRunId);
|
|
categoryEvent.ArrInstanceId.ShouldBeNull();
|
|
categoryEvent.DownloadClientId.ShouldBe(downloadClient.Id);
|
|
categoryEvent.IsDryRun.ShouldBe(false);
|
|
categoryEvent.StrikeId.ShouldBeNull();
|
|
categoryEvent.TrackingId.ShouldBeNull();
|
|
categoryEvent.SearchStatus.ShouldBeNull();
|
|
categoryEvent.CompletedAt.ShouldBeNull();
|
|
categoryEvent.CycleId.ShouldBeNull();
|
|
categoryEvent.ItemTitle.ShouldBe("NoLinks.Movie.2024");
|
|
categoryEvent.ItemHash.ShouldBe("unlinked_hash_xyz");
|
|
categoryEvent.OldCategory.ShouldBe("completed");
|
|
categoryEvent.NewCategory.ShouldBe("unlinked");
|
|
categoryEvent.IsCategoryTag.ShouldBe(false);
|
|
|
|
// Assert: Notification sent
|
|
await _fixture.NotificationPublisher.Received(1)
|
|
.NotifyCategoryChanged("completed", "unlinked", false);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopRule_StopsTheDownloadAndPublishesDownloadStoppedEvent()
|
|
{
|
|
// Arrange
|
|
RecordingDownloadService downloadService = SetupSeedingRuleRun(
|
|
SeedingRuleAction.Stop,
|
|
CreateMockTorrentItem("stop_hash", "Seeding.Movie.2024", category: "completed", ratio: 2.0, seedingHours: 10));
|
|
|
|
DownloadCleaner sut = CreateSut();
|
|
|
|
// Act
|
|
Task executeTask = sut.ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
|
|
|
// Assert: the torrent was stopped and stayed in the client
|
|
downloadService.StoppedHashes.ShouldBe(["stop_hash"]);
|
|
downloadService.DeletedHashes.ShouldBeEmpty();
|
|
|
|
List<AppEvent> events = await _fixture.EventsContext.Events.ToListAsync();
|
|
AppEvent stoppedEvent = events.ShouldHaveSingleItem();
|
|
stoppedEvent.EventType.ShouldBe(EventType.DownloadStopped);
|
|
stoppedEvent.Severity.ShouldBe(EventSeverity.Important);
|
|
stoppedEvent.ItemTitle.ShouldBe("Seeding.Movie.2024");
|
|
stoppedEvent.ItemHash.ShouldBe("stop_hash");
|
|
stoppedEvent.CleanedCategory.ShouldBe("completed");
|
|
stoppedEvent.SeedRatio.ShouldBe(2.0);
|
|
stoppedEvent.SeedingTimeHours.ShouldBe(10.0);
|
|
stoppedEvent.CleanReason.ShouldBe(CleanReason.MaxRatioReached);
|
|
|
|
await _fixture.NotificationPublisher.Received(1)
|
|
.NotifyDownloadStopped(2.0, TimeSpan.FromHours(10), "completed", CleanReason.MaxRatioReached);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopRule_SecondRunOverTheStoppedDownload_DoesNothing()
|
|
{
|
|
// Arrange
|
|
ITorrentItemWrapper torrent = CreateMockTorrentItem(
|
|
"stop_hash", "Seeding.Movie.2024", category: "completed", ratio: 2.0, seedingHours: 10);
|
|
RecordingDownloadService downloadService = SetupSeedingRuleRun(SeedingRuleAction.Stop, torrent);
|
|
|
|
Task firstRun = CreateSut().ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(firstRun);
|
|
|
|
// The clients keep listing a stopped torrent as seeding.
|
|
torrent.IsStopped.Returns(true);
|
|
|
|
// Act
|
|
Task secondRun = CreateSut().ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(secondRun);
|
|
|
|
// Assert: no second stop and no second event
|
|
downloadService.StoppedHashes.ShouldBe(["stop_hash"]);
|
|
|
|
List<AppEvent> events = await _fixture.EventsContext.Events.ToListAsync();
|
|
events.ShouldHaveSingleItem();
|
|
|
|
await _fixture.NotificationPublisher.Received(1)
|
|
.NotifyDownloadStopped(Arg.Any<double>(), Arg.Any<TimeSpan>(), Arg.Any<string>(), Arg.Any<CleanReason>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteRule_StillDeletesADownloadThatIsAlreadyStopped()
|
|
{
|
|
// Arrange: Deluge and Transmission list paused but finished torrents as seeding
|
|
RecordingDownloadService downloadService = SetupSeedingRuleRun(
|
|
SeedingRuleAction.Delete,
|
|
CreateMockTorrentItem(
|
|
"paused_hash", "Paused.Movie.2024", category: "completed", ratio: 2.0, seedingHours: 10, isStopped: true));
|
|
|
|
DownloadCleaner sut = CreateSut();
|
|
|
|
// Act
|
|
Task executeTask = sut.ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
|
|
|
// Assert
|
|
downloadService.DeletedHashes.ShouldBe(["paused_hash"]);
|
|
downloadService.StoppedHashes.ShouldBeEmpty();
|
|
|
|
List<AppEvent> events = await _fixture.EventsContext.Events.ToListAsync();
|
|
events.ShouldHaveSingleItem().EventType.ShouldBe(EventType.DownloadCleaned);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownRuleAction_LeavesTheDownloadAlone()
|
|
{
|
|
// Arrange
|
|
RecordingDownloadService downloadService = SetupSeedingRuleRun(
|
|
SeedingRuleAction.Delete,
|
|
CreateMockTorrentItem("future_hash", "Future.Movie.2024", category: "completed", ratio: 2.0, seedingHours: 10));
|
|
|
|
// Only a newer version can write an action this build does not know.
|
|
await _fixture.DataContext.Database.ExecuteSqlRawAsync(
|
|
"""UPDATE "QBitSeedingRules" SET "Action" = 'fromthefuture'""");
|
|
|
|
DownloadCleaner sut = CreateSut();
|
|
|
|
// Act
|
|
Task executeTask = sut.ExecuteAsync();
|
|
await _fixture.TimeProvider.AdvanceUntilCompleted(executeTask);
|
|
|
|
// Assert
|
|
downloadService.DeletedHashes.ShouldBeEmpty();
|
|
downloadService.StoppedHashes.ShouldBeEmpty();
|
|
|
|
List<AppEvent> events = await _fixture.EventsContext.Events.ToListAsync();
|
|
events.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds a client with one seeding rule and a real DownloadService over the given torrents.
|
|
/// The cleanup loop itself runs, rather than a mocked CleanDownloadsAsync.
|
|
/// </summary>
|
|
private RecordingDownloadService SetupSeedingRuleRun(SeedingRuleAction action, params ITorrentItemWrapper[] torrents)
|
|
{
|
|
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
|
DownloadClientConfig downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
|
TestDataContextFactory.AddSeedingRule(_fixture.DataContext, action: action);
|
|
|
|
IDynamicHttpClientProvider httpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
|
httpClientProvider.CreateClient(Arg.Any<DownloadClientConfig>()).Returns(new HttpClient());
|
|
|
|
RecordingDownloadService downloadService = new(
|
|
Substitute.For<ILogger<TorrentDownloadService>>(),
|
|
Substitute.For<IFilenameEvaluator>(),
|
|
Substitute.For<IStriker>(),
|
|
_fixture.DryRunInterceptor,
|
|
_fixture.HardLinkFileService,
|
|
httpClientProvider,
|
|
_fixture.EventPublisher,
|
|
_fixture.BlocklistProvider,
|
|
downloadClient,
|
|
Substitute.For<IQueueRuleEvaluator>(),
|
|
new SeedingRuleEvaluator(),
|
|
System.TimeProvider.System,
|
|
torrents.ToList());
|
|
|
|
_fixture.DryRunInterceptor
|
|
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
|
|
.ReturnsForAnyArgs(ci => ci.ArgAt<Func<Task>>(0).Invoke());
|
|
|
|
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
|
.Returns(downloadService);
|
|
|
|
// No arr-managed downloads
|
|
_fixture.SetupArrQueueReader();
|
|
|
|
return downloadService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A real DownloadService, so the cleanup loop runs.
|
|
/// The client calls are recorded rather than sent.
|
|
/// </summary>
|
|
private sealed class RecordingDownloadService : TorrentDownloadService
|
|
{
|
|
private readonly List<ITorrentItemWrapper> _seedingDownloads;
|
|
|
|
public RecordingDownloadService(
|
|
ILogger<TorrentDownloadService> logger,
|
|
IFilenameEvaluator filenameEvaluator,
|
|
IStriker striker,
|
|
IDryRunInterceptor dryRunInterceptor,
|
|
IHardLinkFileService hardLinkFileService,
|
|
IDynamicHttpClientProvider httpClientProvider,
|
|
IEventPublisher eventPublisher,
|
|
IBlocklistProvider blocklistProvider,
|
|
DownloadClientConfig downloadClientConfig,
|
|
IQueueRuleEvaluator queueRuleEvaluator,
|
|
ISeedingRuleEvaluator seedingRuleEvaluator,
|
|
TimeProvider timeProvider,
|
|
List<ITorrentItemWrapper> seedingDownloads
|
|
) : base(
|
|
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService, httpClientProvider,
|
|
eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator, timeProvider)
|
|
{
|
|
_seedingDownloads = seedingDownloads;
|
|
}
|
|
|
|
public List<string> DeletedHashes { get; } = [];
|
|
|
|
public List<string> StoppedHashes { get; } = [];
|
|
|
|
public override Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles)
|
|
{
|
|
DeletedHashes.Add(item.DownloadId);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public override Task StopDownload(IDownloadItem item)
|
|
{
|
|
StoppedHashes.Add(item.DownloadId);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public override Task<List<ITorrentItemWrapper>> GetSeedingDownloads() => Task.FromResult(_seedingDownloads);
|
|
|
|
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(
|
|
List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) => downloads;
|
|
|
|
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(
|
|
List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) => [];
|
|
|
|
protected override Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(
|
|
ITorrentItemWrapper torrent) => throw new NotSupportedException();
|
|
|
|
public override Task<List<IDownloadItem>> GetAllDownloadsLite() =>
|
|
Task.FromResult(_seedingDownloads.Cast<IDownloadItem>().ToList());
|
|
|
|
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<IDownloadItem> downloads) =>
|
|
Task.FromResult<IReadOnlyList<string>>([]);
|
|
|
|
public override Task LoginAsync() => Task.CompletedTask;
|
|
|
|
public override Task<HealthCheckResult> HealthCheckAsync() => throw new NotSupportedException();
|
|
|
|
public override Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(
|
|
string downloadId, IReadOnlyList<string> ignoredDownloads) => throw new NotSupportedException();
|
|
|
|
protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) =>
|
|
throw new NotSupportedException();
|
|
|
|
public override Task CreateCategoryAsync(string name) => throw new NotSupportedException();
|
|
|
|
public override Task<BlockFilesResult> BlockUnwantedFilesAsync(
|
|
string downloadId, IReadOnlyList<string> ignoredDownloads) => throw new NotSupportedException();
|
|
}
|
|
|
|
private static ITorrentItemWrapper CreateMockTorrentItem(
|
|
string hash,
|
|
string name,
|
|
string? category = null,
|
|
double ratio = 0,
|
|
double seedingHours = 0,
|
|
bool isStopped = false)
|
|
{
|
|
var mock = Substitute.For<ITorrentItemWrapper>();
|
|
mock.DownloadId.Returns(hash);
|
|
mock.Name.Returns(name);
|
|
mock.Category.Returns(category);
|
|
mock.Ratio.Returns(ratio);
|
|
mock.SeedingTimeSeconds.Returns((long)TimeSpan.FromHours(seedingHours).TotalSeconds);
|
|
mock.IsStopped.Returns(isStopped);
|
|
mock.IsIgnored(Arg.Any<List<string>>()).Returns(ci =>
|
|
{
|
|
var ignoredList = ci.Arg<List<string>>();
|
|
return ignoredList.Contains(name, StringComparer.InvariantCultureIgnoreCase);
|
|
});
|
|
return mock;
|
|
}
|
|
}
|