mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375094862c | ||
|
|
58a72cef0f | ||
|
|
4ceff127a7 | ||
|
|
c07b811cf8 | ||
|
|
b16fa70774 | ||
|
|
b343165644 | ||
|
|
02dff0bb9b |
27
.github/workflows/build-docker.yml
vendored
27
.github/workflows/build-docker.yml
vendored
@@ -11,6 +11,11 @@ on:
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
@@ -34,18 +39,32 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
if [[ -n "$inputVersion" ]]; then
|
||||
# Version provided via input (manual release)
|
||||
branch="main"
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag="$inputVersion"
|
||||
version="$inputVersion"
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
elif [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
# Tag push
|
||||
branch=${githubRef##*/}
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag=${branch#v}
|
||||
version=${branch#v}
|
||||
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
@@ -136,12 +155,10 @@ jobs:
|
||||
VERSION=${{ env.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
outputs: |
|
||||
type=image
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: ${{ inputs.push_docker }}
|
||||
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
|
||||
tags: |
|
||||
${{ env.githubTags }}
|
||||
# Enable BuildKit cache for faster builds
|
||||
|
||||
24
.github/workflows/build-executable.yml
vendored
24
.github/workflows/build-executable.yml
vendored
@@ -124,27 +124,3 @@ jobs:
|
||||
name: executable-${{ matrix.platform }}
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
|
||||
# Consolidate all executable artifacts
|
||||
consolidate:
|
||||
needs: build-platform
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all platform artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: executable-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: |
|
||||
echo "Consolidated executable artifacts:"
|
||||
find ./artifacts -type f -name "*.zip" | sort
|
||||
|
||||
- name: Upload consolidated artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cleanuparr-executables
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
|
||||
4
.github/workflows/build-macos-installer.yml
vendored
4
.github/workflows/build-macos-installer.yml
vendored
@@ -350,8 +350,8 @@ jobs:
|
||||
# Copy uninstall script to app bundle for user access
|
||||
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
|
||||
|
||||
# Determine package name
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Determine package name - if app_version input was provided, it's a release build
|
||||
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
|
||||
|
||||
66
.github/workflows/release.yml
vendored
66
.github/workflows/release.yml
vendored
@@ -8,8 +8,7 @@ on:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., 1.0.0)'
|
||||
required: false
|
||||
default: ''
|
||||
required: true
|
||||
runTests:
|
||||
description: 'Run test suite'
|
||||
type: boolean
|
||||
@@ -57,25 +56,38 @@ jobs:
|
||||
release_version=${GITHUB_REF##refs/tags/}
|
||||
app_version=${release_version#v}
|
||||
is_tag=true
|
||||
elif [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
else
|
||||
# Manual workflow with version
|
||||
app_version="${{ github.event.inputs.version }}"
|
||||
release_version="v$app_version"
|
||||
is_tag=false
|
||||
else
|
||||
# Manual workflow without version
|
||||
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
# Validate version format (x.x.x)
|
||||
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
|
||||
echo "Provided version: $app_version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_version="v$app_version"
|
||||
is_tag=false
|
||||
fi
|
||||
|
||||
|
||||
echo "app_version=$app_version" >> $GITHUB_OUTPUT
|
||||
echo "release_version=$release_version" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "🏷️ Release Version: $release_version"
|
||||
echo "📱 App Version: $app_version"
|
||||
echo "🔖 Is Tag: $is_tag"
|
||||
echo "Release Version: $release_version"
|
||||
echo "App Version: $app_version"
|
||||
echo "Is Tag: $is_tag"
|
||||
|
||||
- name: Check if release already exists
|
||||
run: |
|
||||
if gh release view "${{ steps.version.outputs.release_version }}" &>/dev/null; then
|
||||
echo "❌ Release ${{ steps.version.outputs.release_version }} already exists. Stopping workflow."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Release ${{ steps.version.outputs.release_version }} does not exist. Proceeding."
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@@ -148,6 +160,7 @@ jobs:
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Create GitHub release
|
||||
@@ -176,15 +189,32 @@ jobs:
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Download all artifacts
|
||||
- name: Download executable artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: executable-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Windows installer
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: ./artifacts
|
||||
|
||||
- name: Download macOS installers
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: Cleanuparr-macos-*-installer
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: |
|
||||
echo "📦 Downloaded artifacts:"
|
||||
find ./artifacts -type f -name "*.zip" -o -name "*.pkg" -o -name "*.exe" | sort
|
||||
echo "Downloaded artifacts:"
|
||||
find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | sort
|
||||
echo ""
|
||||
echo "Total files: $(find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | wc -l)"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -196,9 +226,9 @@ jobs:
|
||||
target_commitish: main
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./artifacts/**/*.zip
|
||||
./artifacts/**/*.pkg
|
||||
./artifacts/**/*.exe
|
||||
./artifacts/*.zip
|
||||
./artifacts/*.pkg
|
||||
./artifacts/*.exe
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
id: run-tests
|
||||
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --results-directory ./coverage
|
||||
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -14,18 +15,15 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly ArrClientFactory _arrClientFactory;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public StatusController(
|
||||
ILogger<StatusController> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
ArrClientFactory arrClientFactory)
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
@@ -179,7 +177,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
|
||||
await sonarrClient.TestConnectionAsync(instance);
|
||||
await sonarrClient.HealthCheckAsync(instance);
|
||||
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
@@ -211,7 +209,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
|
||||
await radarrClient.TestConnectionAsync(instance);
|
||||
await radarrClient.HealthCheckAsync(instance);
|
||||
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
@@ -243,7 +241,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
|
||||
await lidarrClient.TestConnectionAsync(instance);
|
||||
await lidarrClient.HealthCheckAsync(instance);
|
||||
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -12,6 +13,7 @@ public static class NotificationsDI
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
@@ -10,7 +12,6 @@ using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
@@ -23,20 +24,18 @@ public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddScoped<IEncryptionService, AesEncryptionService>()
|
||||
.AddScoped<SensitiveDataJsonConverter>()
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<EventPublisher>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddScoped<CertificateValidationService>()
|
||||
.AddScoped<SonarrClient>()
|
||||
.AddScoped<RadarrClient>()
|
||||
.AddScoped<LidarrClient>()
|
||||
.AddScoped<ReadarrClient>()
|
||||
.AddScoped<WhisparrClient>()
|
||||
.AddScoped<ArrClientFactory>()
|
||||
.AddScoped<ISonarrClient, SonarrClient>()
|
||||
.AddScoped<IRadarrClient, RadarrClient>()
|
||||
.AddScoped<ILidarrClient, LidarrClient>()
|
||||
.AddScoped<IReadarrClient, ReadarrClient>()
|
||||
.AddScoped<IWhisparrClient, WhisparrClient>()
|
||||
.AddScoped<IArrClientFactory, ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
@@ -45,17 +44,18 @@ public static class ServicesDI
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<UnixHardLinkFileService>()
|
||||
.AddScoped<WindowsHardLinkFileService>()
|
||||
.AddScoped<ArrQueueIterator>()
|
||||
.AddScoped<DownloadServiceFactory>()
|
||||
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
|
||||
.AddScoped<IWindowsHardLinkFileService, WindowsHardLinkFileService>()
|
||||
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IRuleManager, RuleManager>()
|
||||
.AddScoped<IRuleEvaluator, RuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<BlocklistProvider>()
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record TestArrInstanceRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Mapster;
|
||||
@@ -20,13 +21,16 @@ public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public ArrConfigController(
|
||||
ILogger<ArrConfigController> logger,
|
||||
DataContext dataContext)
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("sonarr")]
|
||||
@@ -124,6 +128,26 @@ public sealed class ArrConfigController : ControllerBase
|
||||
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Whisparr, id);
|
||||
|
||||
[HttpPost("sonarr/instances/test")]
|
||||
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPost("radarr/instances/test")]
|
||||
public Task<IActionResult> TestRadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPost("lidarr/instances/test")]
|
||||
public Task<IActionResult> TestLidarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPost("readarr/instances/test")]
|
||||
public Task<IActionResult> TestReadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPost("whisparr/instances/test")]
|
||||
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
private async Task<IActionResult> GetArrConfig(InstanceType type)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
@@ -260,6 +284,23 @@ public sealed class ArrConfigController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testInstance = request.ToTestInstance();
|
||||
var client = _arrClientFactory.GetClient(type);
|
||||
await client.HealthCheckAsync(testInstance);
|
||||
|
||||
return Ok(new { Message = $"Connection to {type} instance successful" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
|
||||
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetConfigActionName(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => nameof(GetSonarrConfig),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public record SeedingRuleRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
public double MaxRatio { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||
/// </summary>
|
||||
public double MinSeedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hours to seed before removing a download.
|
||||
/// </summary>
|
||||
public double MaxSeedTime { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to delete the source files when cleaning the download.
|
||||
/// </summary>
|
||||
public bool DeleteSourceFiles { get; init; } = true;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public record UpdateDownloadCleanerConfigRequest
|
||||
public sealed record UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
@@ -13,7 +11,7 @@ public record UpdateDownloadCleanerConfigRequest
|
||||
/// </summary>
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public List<CleanCategoryRequest> Categories { get; init; } = [];
|
||||
public List<SeedingRuleRequest> Categories { get; init; } = [];
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
@@ -26,30 +24,9 @@ public record UpdateDownloadCleanerConfigRequest
|
||||
|
||||
public bool UnlinkedUseTag { get; init; }
|
||||
|
||||
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
|
||||
public List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
|
||||
|
||||
public List<string> UnlinkedCategories { get; init; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
}
|
||||
|
||||
public record CleanCategoryRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
public double MaxRatio { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||
/// </summary>
|
||||
public double MinSeedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hours to seed before removing a download.
|
||||
/// </summary>
|
||||
public double MaxSeedTime { get; init; } = -1;
|
||||
}
|
||||
|
||||
@@ -80,22 +80,23 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
|
||||
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
|
||||
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
|
||||
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
|
||||
oldConfig.UnlinkedIgnoredRootDirs = newConfigDto.UnlinkedIgnoredRootDirs;
|
||||
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
oldConfig.Categories.Clear();
|
||||
|
||||
_dataContext.CleanCategories.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.SeedingRules.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
|
||||
|
||||
foreach (var categoryDto in newConfigDto.Categories)
|
||||
{
|
||||
_dataContext.CleanCategories.Add(new CleanCategory
|
||||
_dataContext.SeedingRules.Add(new SeedingRule
|
||||
{
|
||||
Name = categoryDto.Name,
|
||||
MaxRatio = categoryDto.MaxRatio,
|
||||
MinSeedTime = categoryDto.MinSeedTime,
|
||||
MaxSeedTime = categoryDto.MaxSeedTime,
|
||||
DeleteSourceFiles = categoryDto.DeleteSourceFiles,
|
||||
DownloadCleanerConfigId = oldConfig.Id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
public sealed record TestDownloadClientRequest
|
||||
{
|
||||
public DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToTestConfig() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Enabled = true,
|
||||
Name = "Test Client",
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
@@ -18,15 +19,18 @@ public sealed class DownloadClientController : ControllerBase
|
||||
private readonly ILogger<DownloadClientController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||
private readonly IDownloadServiceFactory _downloadServiceFactory;
|
||||
|
||||
public DownloadClientController(
|
||||
ILogger<DownloadClientController> logger,
|
||||
DataContext dataContext,
|
||||
IDynamicHttpClientFactory dynamicHttpClientFactory)
|
||||
IDynamicHttpClientFactory dynamicHttpClientFactory,
|
||||
IDownloadServiceFactory downloadServiceFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_dynamicHttpClientFactory = dynamicHttpClientFactory;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
}
|
||||
|
||||
[HttpGet("download_client")]
|
||||
@@ -146,4 +150,33 @@ public sealed class DownloadClientController : ControllerBase
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("download_client/test")]
|
||||
public async Task<IActionResult> TestDownloadClient([FromBody] TestDownloadClientRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
request.Validate();
|
||||
|
||||
var testConfig = request.ToTestConfig();
|
||||
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
|
||||
var healthResult = await downloadService.HealthCheckAsync();
|
||||
|
||||
if (healthResult.IsHealthy)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
Message = $"Connection to {request.TypeName} successful",
|
||||
ResponseTime = healthResult.ResponseTime.TotalMilliseconds
|
||||
});
|
||||
}
|
||||
|
||||
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
|
||||
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreatePushoverProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestPushoverProviderRequest
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdatePushoverProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -44,6 +44,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -68,6 +69,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
|
||||
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
})
|
||||
@@ -524,6 +526,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
@@ -583,12 +586,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Notifiarr provider");
|
||||
throw;
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,12 +627,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Apprise provider");
|
||||
throw;
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,12 +673,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Ntfy provider");
|
||||
throw;
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,8 +704,207 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
|
||||
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("pushover")]
|
||||
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = newProvider.ApiToken,
|
||||
UserKey = newProvider.UserKey,
|
||||
Devices = newProvider.Devices,
|
||||
Priority = newProvider.Priority,
|
||||
Sound = newProvider.Sound,
|
||||
Retry = newProvider.Retry,
|
||||
Expire = newProvider.Expire,
|
||||
Tags = newProvider.Tags
|
||||
};
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
PushoverConfiguration = pushoverConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Pushover provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("pushover/{id:guid}")]
|
||||
public async Task<IActionResult> UpdatePushoverProvider(Guid id, [FromBody] UpdatePushoverProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Pushover);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Pushover provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = updatedProvider.ApiToken,
|
||||
UserKey = updatedProvider.UserKey,
|
||||
Devices = updatedProvider.Devices,
|
||||
Priority = updatedProvider.Priority,
|
||||
Sound = updatedProvider.Sound,
|
||||
Retry = updatedProvider.Retry,
|
||||
Expire = updatedProvider.Expire,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
|
||||
if (existingProvider.PushoverConfiguration != null)
|
||||
{
|
||||
pushoverConfig = pushoverConfig with { Id = existingProvider.PushoverConfiguration.Id };
|
||||
}
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
PushoverConfiguration = pushoverConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Pushover provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("pushover/test")]
|
||||
public async Task<IActionResult> TestPushoverProvider([FromBody] TestPushoverProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = testRequest.ApiToken,
|
||||
UserKey = testRequest.UserKey,
|
||||
Devices = testRequest.Devices,
|
||||
Priority = testRequest.Priority,
|
||||
Sound = testRequest.Sound,
|
||||
Retry = testRequest.Retry,
|
||||
Expire = testRequest.Expire,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = pushoverConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Pushover provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
namespace Cleanuparr.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy namespace shim; prefer <see cref="UpdateDownloadCleanerConfigRequest"/> from
|
||||
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
|
||||
/// </summary>
|
||||
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.UpdateDownloadCleanerConfigRequest instead")]
|
||||
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
|
||||
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
|
||||
public record UpdateDownloadCleanerConfigDto : UpdateDownloadCleanerConfigRequest;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy namespace shim; prefer <see cref="CleanCategoryRequest"/> from
|
||||
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
|
||||
/// </summary>
|
||||
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.CleanCategoryRequest instead")]
|
||||
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
|
||||
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
|
||||
public record CleanCategoryDto : CleanCategoryRequest;
|
||||
@@ -4,49 +4,34 @@ namespace Cleanuparr.Domain.Entities;
|
||||
/// Universal abstraction for a torrent item across all download clients.
|
||||
/// Provides a unified interface for accessing torrent properties and state.
|
||||
/// </summary>
|
||||
public interface ITorrentItem
|
||||
public interface ITorrentItemWrapper
|
||||
{
|
||||
// Basic identification
|
||||
string Hash { get; }
|
||||
|
||||
string Name { get; }
|
||||
|
||||
// Privacy and tracking
|
||||
bool IsPrivate { get; }
|
||||
IReadOnlyList<string> Trackers { get; }
|
||||
|
||||
// Size and progress
|
||||
long Size { get; }
|
||||
|
||||
double CompletionPercentage { get; }
|
||||
|
||||
long DownloadedBytes { get; }
|
||||
long TotalUploaded { get; }
|
||||
|
||||
// Speed and transfer rates
|
||||
long DownloadSpeed { get; }
|
||||
long UploadSpeed { get; }
|
||||
|
||||
double Ratio { get; }
|
||||
|
||||
// Time tracking
|
||||
long Eta { get; }
|
||||
DateTime? DateAdded { get; }
|
||||
DateTime? DateCompleted { get; }
|
||||
|
||||
long SeedingTimeSeconds { get; }
|
||||
|
||||
// Categories and tags
|
||||
string? Category { get; }
|
||||
IReadOnlyList<string> Tags { get; }
|
||||
string? Category { get; set; }
|
||||
|
||||
// State checking methods
|
||||
bool IsDownloading();
|
||||
|
||||
bool IsStalled();
|
||||
bool IsSeeding();
|
||||
bool IsCompleted();
|
||||
bool IsPaused();
|
||||
bool IsQueued();
|
||||
bool IsChecking();
|
||||
bool IsAllocating();
|
||||
bool IsMetadataDownloading();
|
||||
|
||||
// Filtering methods
|
||||
/// <summary>
|
||||
/// Determines if this torrent should be ignored based on the provided patterns.
|
||||
/// Checks if any pattern matches the torrent name, hash, or tracker.
|
||||
@@ -4,5 +4,6 @@ public enum NotificationProviderType
|
||||
{
|
||||
Notifiarr,
|
||||
Apprise,
|
||||
Ntfy
|
||||
Ntfy,
|
||||
Pushover
|
||||
}
|
||||
|
||||
10
code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs
Normal file
10
code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum PushoverPriority
|
||||
{
|
||||
Lowest = -2,
|
||||
Low = -1,
|
||||
Normal = 0,
|
||||
High = 1,
|
||||
Emergency = 2
|
||||
}
|
||||
36
code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs
Normal file
36
code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public static class PushoverSounds
|
||||
{
|
||||
public const string Pushover = "pushover";
|
||||
public const string Bike = "bike";
|
||||
public const string Bugle = "bugle";
|
||||
public const string Cashregister = "cashregister";
|
||||
public const string Classical = "classical";
|
||||
public const string Cosmic = "cosmic";
|
||||
public const string Falling = "falling";
|
||||
public const string Gamelan = "gamelan";
|
||||
public const string Incoming = "incoming";
|
||||
public const string Intermission = "intermission";
|
||||
public const string Magic = "magic";
|
||||
public const string Mechanical = "mechanical";
|
||||
public const string Pianobar = "pianobar";
|
||||
public const string Siren = "siren";
|
||||
public const string Spacealarm = "spacealarm";
|
||||
public const string Tugboat = "tugboat";
|
||||
public const string Alien = "alien";
|
||||
public const string Climb = "climb";
|
||||
public const string Persistent = "persistent";
|
||||
public const string Echo = "echo";
|
||||
public const string Updown = "updown";
|
||||
public const string Vibrate = "vibrate";
|
||||
public const string None = "none";
|
||||
|
||||
public static readonly string[] All =
|
||||
[
|
||||
Pushover, Bike, Bugle, Cashregister, Classical, Cosmic, Falling,
|
||||
Gamelan, Incoming, Intermission, Magic, Mechanical, Pianobar,
|
||||
Siren, Spacealarm, Tugboat, Alien, Climb, Persistent, Echo,
|
||||
Updown, Vibrate, None
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
@@ -6,6 +6,10 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
@@ -17,6 +21,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
@@ -26,6 +31,10 @@
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the cleanup logic that actually deletes events
|
||||
/// </summary>
|
||||
public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _dbName;
|
||||
|
||||
public EventCleanupServiceIntegrationTests()
|
||||
{
|
||||
_dbName = Guid.NewGuid().ToString();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Setup in-memory database
|
||||
services.AddDbContext<EventsContext>(options =>
|
||||
options.UseInMemoryDatabase(databaseName: _dbName));
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_loggerMock = new Mock<ILogger<EventCleanupService>>();
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
_context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
context.Database.EnsureDeleted();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupService_PreservesRecentEvents()
|
||||
{
|
||||
// Arrange - Add recent events (within retention period)
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
|
||||
context.Events.Add(new AppEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EventType = EventType.QueueItemDeleted,
|
||||
Message = "Recent event 1",
|
||||
Severity = EventSeverity.Information,
|
||||
Timestamp = DateTime.UtcNow.AddDays(-5)
|
||||
});
|
||||
context.Events.Add(new AppEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EventType = EventType.DownloadCleaned,
|
||||
Message = "Recent event 2",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTime.UtcNow.AddDays(-10)
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Verify events exist
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
var count = await context.Events.CountAsync();
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventCleanupService_CanStartAndStop()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.CancelAfter(100);
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Give some time for the service to process
|
||||
await Task.Delay(150);
|
||||
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - the service should complete without throwing
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventCleanupService_HandlesExceptionsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
// Note: In-memory provider doesn't support ExecuteDeleteAsync,
|
||||
// so the cleanup will fail. This test verifies the service handles errors gracefully.
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.CancelAfter(100);
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(150);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - the service should handle the error and continue (log it but not crash)
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to perform event cleanup")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
|
||||
public class EventCleanupServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
|
||||
private readonly ServiceCollection _services;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _dbName;
|
||||
|
||||
public EventCleanupServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<EventCleanupService>>();
|
||||
_services = new ServiceCollection();
|
||||
_dbName = Guid.NewGuid().ToString();
|
||||
|
||||
// Setup in-memory database for testing
|
||||
_services.AddDbContext<EventsContext>(options =>
|
||||
options.UseInMemoryDatabase(databaseName: _dbName));
|
||||
|
||||
_serviceProvider = _services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup the in-memory database
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
context.Database.EnsureDeleted();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_LogsStartMessage()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - start and immediately cancel
|
||||
cts.CancelAfter(100);
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200); // Give it time to process
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("started")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_LogsStopMessage()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.CancelAfter(50);
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopping")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithCorrectParameters()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
|
||||
// Act
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
|
||||
// Assert - service should be created without exception
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_GracefullyHandlesCancellation()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - cancel immediately
|
||||
cts.Cancel();
|
||||
|
||||
// Start should not throw
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should have logged stopped message
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopped")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
|
||||
public class EventPublisherTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
|
||||
private readonly Mock<ILogger<EventPublisher>> _loggerMock;
|
||||
private readonly Mock<INotificationPublisher> _notificationPublisherMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly Mock<IClientProxy> _clientProxyMock;
|
||||
private readonly EventPublisher _publisher;
|
||||
|
||||
public EventPublisherTests()
|
||||
{
|
||||
// Setup in-memory database
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new EventsContext(options);
|
||||
|
||||
// Setup mocks
|
||||
_hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
_loggerMock = new Mock<ILogger<EventPublisher>>();
|
||||
_notificationPublisherMock = new Mock<INotificationPublisher>();
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
_clientProxyMock = new Mock<IClientProxy>();
|
||||
|
||||
// Setup HubContext to return client proxy
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object);
|
||||
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
// Setup dry run interceptor to execute the delegate
|
||||
_dryRunInterceptorMock.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns<Delegate, object[]>(async (del, args) =>
|
||||
{
|
||||
if (del is Func<AppEvent, Task> func && args.Length > 0 && args[0] is AppEvent appEvent)
|
||||
{
|
||||
await func(appEvent);
|
||||
}
|
||||
else if (del is Func<ManualEvent, Task> manualFunc && args.Length > 0 && args[0] is ManualEvent manualEvent)
|
||||
{
|
||||
await manualFunc(manualEvent);
|
||||
}
|
||||
});
|
||||
|
||||
_publisher = new EventPublisher(
|
||||
_context,
|
||||
_hubContextMock.Object,
|
||||
_loggerMock.Object,
|
||||
_notificationPublisherMock.Object,
|
||||
_dryRunInterceptorMock.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.EnsureDeleted();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
#region PublishAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_SavesEventToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.QueueItemDeleted;
|
||||
var message = "Test message";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(eventType, savedEvent.EventType);
|
||||
Assert.Equal(message, savedEvent.Message);
|
||||
Assert.Equal(severity, savedEvent.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WithData_SerializesDataToJson()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.DownloadCleaned;
|
||||
var message = "Download cleaned";
|
||||
var severity = EventSeverity.Information;
|
||||
var data = new { Name = "TestDownload", Hash = "abc123" };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, data);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("TestDownload", savedEvent.Data);
|
||||
Assert.Contains("abc123", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WithTrackingId_SavesTrackingId()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Strike received";
|
||||
var severity = EventSeverity.Warning;
|
||||
var trackingId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, trackingId: trackingId);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(trackingId, savedEvent.TrackingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_NotifiesSignalRClients()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.CategoryChanged;
|
||||
var message = "Category changed";
|
||||
var severity = EventSeverity.Information;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenSignalRFails_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.QueueItemDeleted;
|
||||
var message = "Test message";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
_clientProxyMock.Setup(c => c.SendCoreAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<object[]>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("SignalR connection failed"));
|
||||
|
||||
// Act - should not throw
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert - verify event was still saved
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_NullData_DoesNotSerialize()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.DownloadCleaned;
|
||||
var message = "Test";
|
||||
var severity = EventSeverity.Information;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, data: null);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Null(savedEvent.Data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishManualAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_SavesManualEventToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual event message";
|
||||
var severity = EventSeverity.Warning;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(message, savedEvent.Message);
|
||||
Assert.Equal(severity, savedEvent.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_WithData_SerializesDataToJson()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual event";
|
||||
var severity = EventSeverity.Important;
|
||||
var data = new { ItemName = "TestItem", Count = 5 };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity, data);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("TestItem", savedEvent.Data);
|
||||
Assert.Contains("5", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_NotifiesSignalRClients()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual event";
|
||||
var severity = EventSeverity.Information;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
"ManualEventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DryRun Interceptor Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_UsesDryRunInterceptor()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Test";
|
||||
var severity = EventSeverity.Warning;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
|
||||
It.IsAny<Delegate>(),
|
||||
It.IsAny<object[]>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_UsesDryRunInterceptor()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual test";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
|
||||
It.IsAny<Delegate>(),
|
||||
It.IsAny<object[]>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Serialization Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_SerializesEnumsAsStrings()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.QueueItemDeleted;
|
||||
var message = "Test";
|
||||
var severity = EventSeverity.Important;
|
||||
var data = new { Reason = DeleteReason.Stalled };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, data);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Stalled", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_HandlesComplexData()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.DownloadCleaned;
|
||||
var message = "Test";
|
||||
var severity = EventSeverity.Information;
|
||||
var data = new
|
||||
{
|
||||
Items = new[] { "item1", "item2" },
|
||||
Nested = new { Value = 123 },
|
||||
NullableValue = (string?)null
|
||||
};
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, data);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("item1", savedEvent.Data);
|
||||
Assert.Contains("123", savedEvent.Data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishQueueItemDeleted Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishQueueItemDeleted_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "abc123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishQueueItemDeleted(removeFromClient: true, DeleteReason.Stalled);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventType.QueueItemDeleted, savedEvent.EventType);
|
||||
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Test Download", savedEvent.Data);
|
||||
Assert.Contains("abc123", savedEvent.Data);
|
||||
Assert.Contains("Stalled", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishQueueItemDeleted_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "abc123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyQueueItemDeleted(false, DeleteReason.FailedImport), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishDownloadCleaned Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishDownloadCleaned_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Cleaned Download");
|
||||
ContextProvider.Set("hash", "def456");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishDownloadCleaned(
|
||||
ratio: 2.5,
|
||||
seedingTime: TimeSpan.FromHours(48),
|
||||
categoryName: "movies",
|
||||
reason: CleanReason.MaxSeedTimeReached);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventType.DownloadCleaned, savedEvent.EventType);
|
||||
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Cleaned Download", savedEvent.Data);
|
||||
Assert.Contains("def456", savedEvent.Data);
|
||||
Assert.Contains("movies", savedEvent.Data);
|
||||
Assert.Contains("MaxSeedTimeReached", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishDownloadCleaned_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test");
|
||||
ContextProvider.Set("hash", "xyz");
|
||||
|
||||
var ratio = 1.5;
|
||||
var seedingTime = TimeSpan.FromHours(24);
|
||||
var categoryName = "tv";
|
||||
var reason = CleanReason.MaxRatioReached;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishDownloadCleaned(ratio, seedingTime, categoryName, reason);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishSearchNotTriggered Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchNotTriggered_SavesManualEvent()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:8989"));
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchNotTriggered("abc123", "Test Item");
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventSeverity.Warning, savedEvent.Severity);
|
||||
Assert.Contains("Replacement search was not triggered", savedEvent.Message);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Test Item", savedEvent.Data);
|
||||
Assert.Contains("abc123", savedEvent.Data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishRecurringItem Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishRecurringItem_SavesManualEvent()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Radarr);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:7878"));
|
||||
|
||||
// Act
|
||||
await _publisher.PublishRecurringItem("hash123", "Recurring Item", 5);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
|
||||
Assert.Contains("keeps coming back", savedEvent.Message);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Recurring Item", savedEvent.Data);
|
||||
Assert.Contains("hash123", savedEvent.Data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishCategoryChanged Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishCategoryChanged_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Category Test");
|
||||
ContextProvider.Set("hash", "cat123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("oldCat", "newCat", isTag: false);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventType.CategoryChanged, savedEvent.EventType);
|
||||
Assert.Equal(EventSeverity.Information, savedEvent.Severity);
|
||||
Assert.Contains("Category changed from 'oldCat' to 'newCat'", savedEvent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishCategoryChanged_WithTag_SavesCorrectMessage()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Tag Test");
|
||||
ContextProvider.Set("hash", "tag123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("", "cleanuperr-done", isTag: true);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Contains("Tag 'cleanuperr-done' added", savedEvent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishCategoryChanged_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test");
|
||||
ContextProvider.Set("hash", "xyz");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("old", "new", isTag: true);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyCategoryChanged("old", "new", true), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
|
||||
|
||||
public class ArrClientFactoryTests
|
||||
{
|
||||
private readonly Mock<ISonarrClient> _sonarrClientMock;
|
||||
private readonly Mock<IRadarrClient> _radarrClientMock;
|
||||
private readonly Mock<ILidarrClient> _lidarrClientMock;
|
||||
private readonly Mock<IReadarrClient> _readarrClientMock;
|
||||
private readonly Mock<IWhisparrClient> _whisparrClientMock;
|
||||
private readonly ArrClientFactory _factory;
|
||||
|
||||
public ArrClientFactoryTests()
|
||||
{
|
||||
_sonarrClientMock = new Mock<ISonarrClient>();
|
||||
_radarrClientMock = new Mock<IRadarrClient>();
|
||||
_lidarrClientMock = new Mock<ILidarrClient>();
|
||||
_readarrClientMock = new Mock<IReadarrClient>();
|
||||
_whisparrClientMock = new Mock<IWhisparrClient>();
|
||||
|
||||
_factory = new ArrClientFactory(
|
||||
_sonarrClientMock.Object,
|
||||
_radarrClientMock.Object,
|
||||
_lidarrClientMock.Object,
|
||||
_readarrClientMock.Object,
|
||||
_whisparrClientMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region GetClient Tests
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Sonarr_ReturnsSonarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Sonarr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_sonarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Radarr_ReturnsRadarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Radarr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_radarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Lidarr_ReturnsLidarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Lidarr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_lidarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Readarr_ReturnsReadarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Readarr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_readarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Whisparr_ReturnsWhisparrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Whisparr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_whisparrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_UnsupportedType_ThrowsNotImplementedException()
|
||||
{
|
||||
// Arrange
|
||||
var unsupportedType = (InstanceType)999;
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotImplementedException>(() => _factory.GetClient(unsupportedType));
|
||||
Assert.Contains("not yet supported", exception.Message);
|
||||
Assert.Contains("999", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public void GetClient_AllSupportedTypes_ReturnsNonNullClient(InstanceType instanceType)
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(instanceType);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsAssignableFrom<IArrClient>(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public void GetClient_CalledMultipleTimes_ReturnsSameInstance(InstanceType instanceType)
|
||||
{
|
||||
// Act
|
||||
var result1 = _factory.GetClient(instanceType);
|
||||
var result2 = _factory.GetClient(instanceType);
|
||||
|
||||
// Assert
|
||||
Assert.Same(result1, result2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using System.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.BlacklistSync;
|
||||
|
||||
public class BlacklistSynchronizerTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<BlacklistSynchronizer>> _loggerMock;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly Mock<IDownloadServiceFactory> _downloadServiceFactoryMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly BlacklistSynchronizer _synchronizer;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public BlacklistSynchronizerTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<BlacklistSynchronizer>>();
|
||||
|
||||
// Use SQLite in-memory with shared connection to support complex types
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_dataContext = new DataContext(options);
|
||||
_dataContext.Database.EnsureCreated();
|
||||
|
||||
_downloadServiceFactoryMock = new Mock<IDownloadServiceFactory>();
|
||||
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
// Setup interceptor to execute the action with params using DynamicInvoke
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
var result = action.DynamicInvoke(parameters);
|
||||
if (result is Task task)
|
||||
{
|
||||
return task;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Setup mock HTTP handler for FileReader
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
|
||||
var httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
_fileReader = new FileReader(httpClientFactoryMock.Object);
|
||||
|
||||
_synchronizer = new BlacklistSynchronizer(
|
||||
_loggerMock.Object,
|
||||
_dataContext,
|
||||
_downloadServiceFactoryMock.Object,
|
||||
_fileReader,
|
||||
_dryRunInterceptorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
#region ExecuteAsync - Disabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenDisabled_ReturnsEarlyWithoutProcessing()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: false);
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync - Path Not Configured Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenPathNotConfigured_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: null);
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("path is not configured")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenPathIsWhitespace_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: " ");
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("path is not configured")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync - No Clients Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenNoQBittorrentClients_LogsDebugAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse("pattern1\npattern2");
|
||||
|
||||
// Don't add any download clients
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenOnlyDelugeClients_LogsDebugAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse("pattern1\npattern2");
|
||||
|
||||
// Add only a Deluge client
|
||||
await AddDownloadClient(DownloadClientTypeName.Deluge, enabled: true);
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenDisabledQBittorrentClient_DoesNotProcess()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse("pattern1\npattern2");
|
||||
|
||||
// Add a disabled qBittorrent client
|
||||
await AddDownloadClient(DownloadClientTypeName.qBittorrent, enabled: false);
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync - Already Synced Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenClientAlreadySynced_SkipsClient()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = "pattern1\npattern2";
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse(patterns);
|
||||
|
||||
var clientId = await AddDownloadClient(DownloadClientTypeName.qBittorrent, enabled: true);
|
||||
|
||||
// Calculate the expected hash (same as ComputeHash in BlacklistSynchronizer)
|
||||
var cleanPatterns = string.Join('\n', patterns.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
var hash = ComputeHash(cleanPatterns);
|
||||
|
||||
// Add sync history for this client with the same hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = hash,
|
||||
DownloadClientId = clientId
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("already synced")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync - Dry Run Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UsesDryRunInterceptor()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse("pattern1\npattern2");
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert - Verify interceptor was called (with Delegate, not Func<object, object, Task>)
|
||||
_dryRunInterceptorMock.Verify(
|
||||
d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task SetupBlacklistSyncConfig(bool enabled, string? blacklistPath = null)
|
||||
{
|
||||
var config = new BlacklistSyncConfig
|
||||
{
|
||||
Enabled = enabled,
|
||||
BlacklistPath = blacklistPath
|
||||
};
|
||||
|
||||
_dataContext.BlacklistSyncConfigs.Add(config);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<Guid> AddDownloadClient(DownloadClientTypeName typeName, bool enabled)
|
||||
{
|
||||
var client = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Test {typeName} Client",
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://test.example.com"),
|
||||
Enabled = enabled
|
||||
};
|
||||
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return client.Id;
|
||||
}
|
||||
|
||||
private void SetupHttpResponse(string content)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(content)
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeItemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new DelugeItem(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = expectedHash,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = expectedName,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPrivate_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Private = true,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Size = expectedSize,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1024, 0.0)]
|
||||
[InlineData(512, 1024, 50.0)]
|
||||
[InlineData(768, 1024, 75.0)]
|
||||
[InlineData(1024, 1024, 100.0)]
|
||||
[InlineData(0, 0, 0.0)] // Edge case: zero size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
TotalDone = totalDone,
|
||||
Size = size,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker2.example.com/announce" },
|
||||
new() { Url = "udp://tracker3.example.com:1337/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker1.example.com/announce" },
|
||||
new() { Url = "udp://tracker1.example.com:1337/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://valid.example.com/announce" },
|
||||
new() { Url = "invalid-url" },
|
||||
new() { Url = "" },
|
||||
new() { Url = null! }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = null!,
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeItemWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new DelugeItemWrapper(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = expectedHash,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = expectedName,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPrivate_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Private = true,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Size = expectedSize,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1024, 0.0)]
|
||||
[InlineData(512, 1024, 50.0)]
|
||||
[InlineData(768, 1024, 75.0)]
|
||||
[InlineData(1024, 1024, 100.0)]
|
||||
[InlineData(0, 0, 0.0)] // Edge case: zero size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
TotalDone = totalDone,
|
||||
Size = size,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
|
||||
[InlineData(0L, 0L)]
|
||||
public void DownloadedBytes_ReturnsCorrectValue(long totalDone, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
TotalDone = totalDone,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2.0f, 2.0)]
|
||||
[InlineData(0.5f, 0.5)]
|
||||
[InlineData(1.0f, 1.0)]
|
||||
[InlineData(0.0f, 0.0)]
|
||||
public void Ratio_ReturnsCorrectValue(float ratio, double expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Ratio = ratio,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Ratio;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3600UL, 3600L)] // 1 hour
|
||||
[InlineData(0UL, 0L)]
|
||||
[InlineData(86400UL, 86400L)] // 1 day
|
||||
public void Eta_ReturnsCorrectValue(ulong eta, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Eta = eta,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(86400L, 86400L)] // 1 day
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(3600L, 3600L)] // 1 hour
|
||||
public void SeedingTimeSeconds_ReturnsCorrectValue(long seedingTime, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
SeedingTime = seedingTime,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
var ignoredDownloads = new[] { "abc123" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingCategory_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Label = "test-category",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
var ignoredDownloads = new[] { "test-category" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTracker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
var ignoredDownloads = new[] { "tracker.example.com" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_NotMatching_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Label = "some-category",
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
var ignoredDownloads = new[] { "notmatching" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024, 1024L * 1024)] // 1MB/s
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(500L, 500L)]
|
||||
public void DownloadSpeed_ReturnsCorrectValue(long downloadSpeed, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
DownloadSpeed = downloadSpeed,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadSpeed;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_Setter_SetsLabel()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Label = "original-category",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
wrapper.Category = "new-category";
|
||||
|
||||
// Assert
|
||||
wrapper.Category.ShouldBe("new-category");
|
||||
downloadStatus.Label.ShouldBe("new-category");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Downloading", true)]
|
||||
[InlineData("downloading", true)]
|
||||
[InlineData("DOWNLOADING", true)]
|
||||
[InlineData("Seeding", false)]
|
||||
[InlineData("Paused", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsDownloading_ReturnsCorrectValue(string? state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
State = state,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsDownloading();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Downloading", 0, 0UL, true)] // Downloading with no speed and no ETA = stalled
|
||||
[InlineData("Downloading", 1000, 0UL, false)] // Has download speed = not stalled
|
||||
[InlineData("Downloading", 0, 100UL, false)] // Has ETA = not stalled
|
||||
[InlineData("Downloading", 1000, 100UL, false)] // Has both = not stalled
|
||||
[InlineData("Seeding", 0, 0UL, false)] // Not downloading state = not stalled
|
||||
[InlineData("Paused", 0, 0UL, false)] // Not downloading state = not stalled
|
||||
[InlineData(null, 0, 0UL, false)] // Null state = not stalled
|
||||
public void IsStalled_ReturnsCorrectValue(string? state, long downloadSpeed, ulong eta, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
State = state,
|
||||
DownloadSpeed = downloadSpeed,
|
||||
Eta = eta,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsStalled();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
private readonly DelugeServiceFixture _fixture;
|
||||
|
||||
public DelugeServiceDCTests(DelugeServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class GetSeedingDownloads_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public GetSeedingDownloads_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiltersSeedingState()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "Downloading", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, item => Assert.NotNull(item.Hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "SEEDING", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync((List<DownloadStatus>?)null);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "", Name = "No Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToBeCleanedAsync_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToBeCleanedAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, x => x.Category == "movies");
|
||||
Assert.Contains(result, x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmptyList_WhenNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToChangeCategoryAsync_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToChangeCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsDownloadsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCategoryAsync_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public CreateCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatesLabel_WhenMissing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.CreateLabel("new-label"))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("new-label");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel("new-label"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsCreation_WhenLabelExists()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string> { "existing" });
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("existing");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string> { "Existing" });
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("existing");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteDownload_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public DeleteDownload_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsClientDelete()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizesHashToLowercase()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "UPPERCASE-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsClientDeleteWithoutSourceFiles()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeCategoryForNoHardLinksAsync_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public ChangeCategoryForNoHardLinksAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingHash_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingName_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingCategory_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExceptionGettingFiles_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoHardlinks_ChangesLabel()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabel("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasHardlinks_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileNotFound_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkippedFiles_IgnoredInCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "file1.mkv" } },
|
||||
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "file2.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithIgnoredRootDir_PopulatesFileCounts()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked",
|
||||
UnlinkedIgnoredRootDirs = ["/ignore"]
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishesCategoryChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - EventPublisher is not mocked, so we just verify the method completed
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabel("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<DelugeService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IDelugeClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public DelugeServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<DelugeService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IDelugeClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public DelugeService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.Deluge,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8112"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = ""
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new DelugeService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
private readonly DelugeServiceFixture _fixture;
|
||||
|
||||
public DelugeServiceTests(DelugeServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_BasicScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentNotFound_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "nonexistent";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync((DownloadStatus?)null);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = true,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFilesUnwanted_DeletesFromClient()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0 } },
|
||||
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 1 } }
|
||||
}
|
||||
});
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SomeFilesWanted_DoesNotRemove()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0 } },
|
||||
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string category = "test-category";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Label = category,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByTrackerDomain_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string trackerDomain = "tracker.example.com";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new Tracker { Url = $"https://{trackerDomain}/announce" }
|
||||
},
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotDownloadingState_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Seeding",
|
||||
Private = false,
|
||||
DownloadSpeed = 0,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 0,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
DownloadSpeed = 0,
|
||||
Eta = 0,
|
||||
Private = false,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DownloadServiceFactoryTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<DownloadServiceFactory>> _loggerMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly DownloadServiceFactory _factory;
|
||||
private readonly MemoryCache _memoryCache;
|
||||
|
||||
public DownloadServiceFactoryTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DownloadServiceFactory>>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Use real MemoryCache - mocks don't work properly with cache operations
|
||||
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
|
||||
services.AddSingleton<IMemoryCache>(_memoryCache);
|
||||
|
||||
// Register loggers
|
||||
services.AddSingleton(Mock.Of<ILogger<QBitService>>());
|
||||
services.AddSingleton(Mock.Of<ILogger<DelugeService>>());
|
||||
services.AddSingleton(Mock.Of<ILogger<TransmissionService>>());
|
||||
services.AddSingleton(Mock.Of<ILogger<UTorrentService>>());
|
||||
|
||||
services.AddSingleton(Mock.Of<IFilenameEvaluator>());
|
||||
services.AddSingleton(Mock.Of<IStriker>());
|
||||
services.AddSingleton(Mock.Of<IDryRunInterceptor>());
|
||||
services.AddSingleton(Mock.Of<IHardLinkFileService>());
|
||||
|
||||
// IDynamicHttpClientProvider must return a real HttpClient for download services
|
||||
var httpClientProviderMock = new Mock<IDynamicHttpClientProvider>();
|
||||
httpClientProviderMock.Setup(p => p.CreateClient(It.IsAny<DownloadClientConfig>())).Returns(new HttpClient());
|
||||
services.AddSingleton(httpClientProviderMock.Object);
|
||||
|
||||
services.AddSingleton(Mock.Of<IRuleEvaluator>());
|
||||
services.AddSingleton(Mock.Of<IRuleManager>());
|
||||
|
||||
// UTorrentService needs ILoggerFactory
|
||||
services.AddLogging();
|
||||
|
||||
// EventPublisher requires specific constructor arguments
|
||||
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var eventsContext = new EventsContext(eventsContextOptions);
|
||||
var hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
|
||||
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
services.AddSingleton<IEventPublisher>(new EventPublisher(
|
||||
eventsContext,
|
||||
hubContextMock.Object,
|
||||
Mock.Of<ILogger<EventPublisher>>(),
|
||||
Mock.Of<INotificationPublisher>(),
|
||||
Mock.Of<IDryRunInterceptor>()));
|
||||
|
||||
// BlocklistProvider requires specific constructor arguments
|
||||
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
|
||||
|
||||
services.AddSingleton<IBlocklistProvider>(new BlocklistProvider(
|
||||
Mock.Of<ILogger<BlocklistProvider>>(),
|
||||
scopeFactoryMock.Object,
|
||||
_memoryCache));
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_factory = new DownloadServiceFactory(_loggerMock.Object, _serviceProvider);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_memoryCache.Dispose();
|
||||
}
|
||||
|
||||
#region GetDownloadService Tests
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_QBittorrent_ReturnsQBitService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<QBitService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_Deluge_ReturnsDelugeService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.Deluge);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<DelugeService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_Transmission_ReturnsTransmissionService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.Transmission);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<TransmissionService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_UTorrent_ReturnsUTorrentService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.uTorrent);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<UTorrentService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_UnsupportedType_ThrowsNotSupportedException()
|
||||
{
|
||||
// Arrange
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Unsupported Client",
|
||||
TypeName = (DownloadClientTypeName)999, // Invalid type
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://test.example.com"),
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotSupportedException>(() => _factory.GetDownloadService(config));
|
||||
Assert.Contains("not supported", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_DisabledClient_LogsWarningButReturnsService()
|
||||
{
|
||||
// Arrange
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Disabled qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://test.example.com"),
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_EnabledClient_DoesNotLogWarning()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DownloadClientTypeName.qBittorrent, typeof(QBitService))]
|
||||
[InlineData(DownloadClientTypeName.Deluge, typeof(DelugeService))]
|
||||
[InlineData(DownloadClientTypeName.Transmission, typeof(TransmissionService))]
|
||||
[InlineData(DownloadClientTypeName.uTorrent, typeof(UTorrentService))]
|
||||
public void GetDownloadService_AllSupportedTypes_ReturnCorrectServiceType(
|
||||
DownloadClientTypeName typeName, Type expectedServiceType)
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(typeName);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType(expectedServiceType, service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_ReturnsNewInstanceEachTime()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
|
||||
|
||||
// Act
|
||||
var service1 = _factory.GetDownloadService(config);
|
||||
var service2 = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(service1, service2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static DownloadClientConfig CreateClientConfig(DownloadClientTypeName typeName)
|
||||
{
|
||||
return new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Test {typeName} Client",
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://test.example.com"),
|
||||
Enabled = true
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class QBitItemTests
|
||||
public class QBitItemWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
|
||||
@@ -14,7 +14,7 @@ public class QBitItemTests
|
||||
var trackers = new List<TorrentTracker>();
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItem(null!, trackers, false));
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItemWrapper(null!, trackers, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -24,7 +24,7 @@ public class QBitItemTests
|
||||
var torrentInfo = new TorrentInfo();
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItem(torrentInfo, null!, false));
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItemWrapper(torrentInfo, null!, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -34,7 +34,7 @@ public class QBitItemTests
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentInfo = new TorrentInfo { Hash = expectedHash };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
@@ -49,7 +49,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Hash = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
@@ -65,7 +65,7 @@ public class QBitItemTests
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentInfo = new TorrentInfo { Name = expectedName };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
@@ -80,7 +80,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
@@ -95,7 +95,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, true);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, true);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
@@ -111,7 +111,7 @@ public class QBitItemTests
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var torrentInfo = new TorrentInfo { Size = expectedSize };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
@@ -126,7 +126,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Size = 0 };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
@@ -145,7 +145,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Progress = progress };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
@@ -155,86 +155,210 @@ public class QBitItemTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
public void DownloadedBytes_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker2.example.com/announce" },
|
||||
new() { Url = "udp://tracker3.example.com:1337/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker1.example.com/announce" },
|
||||
new() { Url = "udp://tracker1.example.com:1337/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://valid.example.com/announce" },
|
||||
new() { Url = "invalid-url" },
|
||||
new() { Url = "" },
|
||||
new() { Url = null }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var expectedDownloaded = 1024L * 1024 * 500; // 500MB
|
||||
var torrentInfo = new TorrentInfo { Downloaded = expectedDownloaded };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedDownloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadedBytes_WithNullValue_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Downloaded = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadSpeed_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSpeed = 1024 * 512; // 512 KB/s
|
||||
var torrentInfo = new TorrentInfo { DownloadSpeed = expectedSpeed };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadSpeed;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSpeed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.5)]
|
||||
[InlineData(1.0)]
|
||||
[InlineData(2.5)]
|
||||
public void Ratio_ReturnsCorrectValue(double expectedRatio)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Ratio = expectedRatio };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Ratio;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedRatio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eta_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedEta = TimeSpan.FromMinutes(30);
|
||||
var torrentInfo = new TorrentInfo { EstimatedTime = expectedEta };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe((long)expectedEta.TotalSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eta_WithNullValue_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { EstimatedTime = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedingTimeSeconds_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTime = TimeSpan.FromHours(5);
|
||||
var torrentInfo = new TorrentInfo { SeedingTime = expectedTime };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe((long)expectedTime.TotalSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedingTimeSeconds_WithNullValue_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { SeedingTime = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTags = new List<string> { "tag1", "tag2", "tag3" };
|
||||
var torrentInfo = new TorrentInfo { Tags = expectedTags.AsReadOnly() };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedTags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_WithNullValue_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Tags = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Tags = new List<string>().AsReadOnly() };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedCategory = "movies";
|
||||
var torrentInfo = new TorrentInfo { Category = expectedCategory };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Category;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedCategory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_WithNullValue_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Category = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Category;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// State checking method tests
|
||||
[Theory]
|
||||
[InlineData(TorrentState.Downloading, true)]
|
||||
@@ -247,7 +371,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsDownloading();
|
||||
@@ -266,7 +390,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsStalled();
|
||||
@@ -286,7 +410,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsSeeding();
|
||||
@@ -295,101 +419,6 @@ public class QBitItemTests
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, false)]
|
||||
[InlineData(0.5, false)]
|
||||
[InlineData(0.99, false)]
|
||||
[InlineData(1.0, true)]
|
||||
public void IsCompleted_ReturnsCorrectValue(double progress, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Progress = progress };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsCompleted();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.PausedDownload, true)]
|
||||
[InlineData(TorrentState.PausedUpload, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsPaused_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPaused();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.QueuedDownload, true)]
|
||||
[InlineData(TorrentState.QueuedUpload, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsQueued_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsQueued();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.CheckingDownload, true)]
|
||||
[InlineData(TorrentState.CheckingUpload, true)]
|
||||
[InlineData(TorrentState.CheckingResumeData, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsChecking_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsChecking();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.Allocating, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsAllocating_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsAllocating();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.FetchingMetadata, true)]
|
||||
[InlineData(TorrentState.ForcedFetchingMetadata, true)]
|
||||
@@ -400,7 +429,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsMetadataDownloading();
|
||||
@@ -415,7 +444,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
@@ -430,7 +459,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "abc123" };
|
||||
|
||||
// Act
|
||||
@@ -440,16 +469,62 @@ public class QBitItemTests
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTag_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Name = "Test Torrent",
|
||||
Hash = "abc123",
|
||||
Tags = new List<string> { "test-tag" }.AsReadOnly()
|
||||
};
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "test-tag" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingCategory_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Name = "Test Torrent",
|
||||
Hash = "abc123",
|
||||
Category = "test-category"
|
||||
};
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "test-category" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTracker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Name = "Test Torrent",
|
||||
Hash = "abc123"
|
||||
};
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "tracker.example.com" };
|
||||
|
||||
// Act
|
||||
@@ -463,12 +538,18 @@ public class QBitItemTests
|
||||
public void IsIgnored_NotMatching_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Name = "Test Torrent",
|
||||
Hash = "abc123",
|
||||
Category = "some-category",
|
||||
Tags = new List<string> { "some-tag" }.AsReadOnly()
|
||||
};
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "notmatching" };
|
||||
|
||||
// Act
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class QBitServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<QBitService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IQBittorrentClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public QBitServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<QBitService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IQBittorrentClientWrapper>();
|
||||
|
||||
// Setup default behavior for DryRunInterceptor to execute actions directly
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public QBitService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.qBittorrent,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = ""
|
||||
};
|
||||
|
||||
// Setup HTTP client provider
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new QBitService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
// Re-setup default DryRunInterceptor behavior
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of BlocklistProvider for testing purposes
|
||||
/// </summary>
|
||||
public static class TestBlocklistProviderFactory
|
||||
{
|
||||
public static BlocklistProvider Create()
|
||||
{
|
||||
var logger = new Mock<ILogger<BlocklistProvider>>().Object;
|
||||
var scopeFactory = new Mock<IServiceScopeFactory>().Object;
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
return new BlocklistProvider(logger, scopeFactory, cache);
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Shouldly;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionItemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new TransmissionItem(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentInfo = new TorrentInfo { HashString = expectedHash };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = null };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentInfo = new TorrentInfo { Name = expectedName };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = null };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L, 1024L, 0.0)]
|
||||
[InlineData(512L, 1024L, 50.0)]
|
||||
[InlineData(768L, 1024L, 75.0)]
|
||||
[InlineData(1024L, 1024L, 100.0)]
|
||||
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
|
||||
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
|
||||
[InlineData(512L, null, 0.0)] // Edge case: null total size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
DownloadedEver = downloadedEver,
|
||||
TotalSize = totalSize
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Announce = "https://tracker2.example.com/announce" },
|
||||
new() { Announce = "udp://tracker3.example.com:1337/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Announce = "https://tracker1.example.com/announce" },
|
||||
new() { Announce = "udp://tracker1.example.com:1337/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://valid.example.com/announce" },
|
||||
new() { Announce = "invalid-url" },
|
||||
new() { Announce = "" },
|
||||
new() { Announce = null }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[0]
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = null
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Shouldly;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionItemWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new TransmissionItemWrapper(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentInfo = new TorrentInfo { HashString = expectedHash };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = null };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentInfo = new TorrentInfo { Name = expectedName };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = null };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L, 1024L, 0.0)]
|
||||
[InlineData(512L, 1024L, 50.0)]
|
||||
[InlineData(768L, 1024L, 75.0)]
|
||||
[InlineData(1024L, 1024L, 100.0)]
|
||||
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
|
||||
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
|
||||
[InlineData(512L, null, 0.0)] // Edge case: null total size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
DownloadedEver = downloadedEver,
|
||||
TotalSize = totalSize
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void DownloadedBytes_ReturnsCorrectValue(long? downloadedEver, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { DownloadedEver = downloadedEver };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L, 512L, 2.0)] // Uploaded more than downloaded
|
||||
[InlineData(512L, 1024L, 0.5)] // Uploaded less than downloaded
|
||||
[InlineData(1024L, 1024L, 1.0)] // Equal
|
||||
[InlineData(0L, 1024L, 0.0)] // No upload
|
||||
[InlineData(1024L, 0L, 0.0)] // No download
|
||||
[InlineData(null, 1024L, 0.0)] // Null upload
|
||||
[InlineData(1024L, null, 0.0)] // Null download
|
||||
[InlineData(null, null, 0.0)] // Both null
|
||||
public void Ratio_ReturnsCorrectValue(long? uploadedEver, long? downloadedEver, double expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
UploadedEver = uploadedEver,
|
||||
DownloadedEver = downloadedEver
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Ratio;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3600L, 3600L)] // 1 hour
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(-1L, -1L)] // Unknown/infinite
|
||||
[InlineData(null, 0L)]
|
||||
public void Eta_ReturnsCorrectValue(long? eta, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Eta = eta };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(86400L, 86400L)] // 1 day
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void SeedingTimeSeconds_ReturnsCorrectValue(long? secondsSeeding, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { SecondsSeeding = secondsSeeding };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = "abc123", Name = "Test Torrent" };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = "abc123", Name = "Test Torrent" };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
var ignoredDownloads = new[] { "abc123" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingCategory_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
HashString = "abc123",
|
||||
Name = "Test Torrent",
|
||||
DownloadDir = "/downloads/test-category"
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
var ignoredDownloads = new[] { "test-category" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTracker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
HashString = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker.example.com/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
var ignoredDownloads = new[] { "tracker.example.com" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_NotMatching_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
HashString = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Labels = new[] { "some-category" },
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker.example.com/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
var ignoredDownloads = new[] { "notmatching" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixture>
|
||||
{
|
||||
private readonly TransmissionServiceFixture _fixture;
|
||||
|
||||
public TransmissionServiceDCTests(TransmissionServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class GetSeedingDownloads_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public GetSeedingDownloads_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiltersStatus5And6()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { HashString = "hash1", Name = "Torrent 1", Status = 5 }, // Seeding
|
||||
new TorrentInfo { HashString = "hash2", Name = "Torrent 2", Status = 4 }, // Downloading
|
||||
new TorrentInfo { HashString = "hash3", Name = "Torrent 3", Status = 6 } // Seeding
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, item => Assert.NotNull(item.Hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { HashString = "", Name = "No Hash", Status = 5 },
|
||||
new TorrentInfo { HashString = "hash1", Name = "Valid Hash", Status = 5 }
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenTorrentsNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = null
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToBeCleanedAsync_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToBeCleanedAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }),
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" }),
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash3", DownloadDir = "/downloads/music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, x => x.Category == "movies");
|
||||
Assert.Contains(result, x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmptyList_WhenNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToChangeCategoryAsync_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToChangeCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }),
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsDownloadsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "", DownloadDir = "/downloads/movies" }),
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCategoryAsync_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public CreateCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsNoOp()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("new-category");
|
||||
|
||||
// Assert - no exceptions thrown, no client calls made
|
||||
_fixture.ClientWrapper.VerifyNoOtherCalls();
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteDownload_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public DeleteDownload_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetsIdFromHash_ThenDeletes()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "test-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { Id = 123, HashString = hash }
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlesNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "nonexistent-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert - no exception thrown
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), It.IsAny<bool>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletesWithData()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "test-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { Id = 123, HashString = hash }
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeCategoryForNoHardLinksAsync_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public ChangeCategoryForNoHardLinksAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingHash_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "", Name = "Test", DownloadDir = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingName_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "", DownloadDir = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingDownloadDir_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingFiles_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "/downloads", Files = null })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingFileStats_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads",
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = null
|
||||
})
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoHardlinks_ChangesLocation()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = baseDownloadDir,
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasHardlinks_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads/movies",
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileNotFound_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads/movies",
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnwantedFiles_IgnoredInCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads/movies",
|
||||
Files = new[]
|
||||
{
|
||||
new TransmissionTorrentFiles { Name = "file1.mkv" },
|
||||
new TransmissionTorrentFiles { Name = "file2.mkv" }
|
||||
},
|
||||
FileStats = new[]
|
||||
{
|
||||
new TransmissionTorrentFileStats { Wanted = false },
|
||||
new TransmissionTorrentFileStats { Wanted = true }
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithIgnoredRootDir_PopulatesFileCounts()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked",
|
||||
UnlinkedIgnoredRootDirs = ["/ignore"]
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads/movies",
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishesCategoryChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = baseDownloadDir,
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - EventPublisher is not mocked, so we just verify the method completed
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendsTargetCategoryToBasePath()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies", "subfolder");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = baseDownloadDir,
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<TransmissionService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<ITransmissionClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public TransmissionServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<TransmissionService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<ITransmissionClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public TransmissionService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.Transmission,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:9091"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = "/transmission"
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new TransmissionService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Moq;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture>
|
||||
{
|
||||
private readonly TransmissionServiceFixture _fixture;
|
||||
|
||||
public TransmissionServiceTests(TransmissionServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_BasicScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentNotFound_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "nonexistent";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = true,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFilesUnwanted_DeletesFromClient()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
FileStats = new[]
|
||||
{
|
||||
new TransmissionTorrentFileStats { Wanted = false },
|
||||
new TransmissionTorrentFileStats { Wanted = false }
|
||||
}
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SomeFilesWanted_DoesNotRemove()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
RateDownload = 1000,
|
||||
FileStats = new[]
|
||||
{
|
||||
new TransmissionTorrentFileStats { Wanted = false },
|
||||
new TransmissionTorrentFileStats { Wanted = true }
|
||||
}
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string category = "test-category";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
Labels = new[] { category },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_MissingFileStatsScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_MissingFileStatsScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilesWithMissingWantedStatus_DoesNotRemove()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
RateDownload = 1000,
|
||||
FileStats = new[]
|
||||
{
|
||||
new TransmissionTorrentFileStats { Wanted = null },
|
||||
new TransmissionTorrentFileStats { Wanted = false }
|
||||
}
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotDownloadingState_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 6,
|
||||
IsPrivate = false,
|
||||
RateDownload = 0,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
RateDownload = 0,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
RateDownload = 1000,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
RateDownload = 0,
|
||||
Eta = 0,
|
||||
IsPrivate = false,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,95 +109,173 @@ public class UTorrentItemWrapperTests
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
|
||||
[InlineData(0L, 0L)]
|
||||
public void DownloadedBytes_ReturnsCorrectValue(long downloaded, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker2.example.com/announce\r\nudp://tracker3.example.com:1337/announce"
|
||||
};
|
||||
var torrentItem = new UTorrentItem { Downloaded = downloaded };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2000, 2.0)] // 2000 permille = 2.0 ratio
|
||||
[InlineData(500, 0.5)] // 500 permille = 0.5 ratio
|
||||
[InlineData(1000, 1.0)] // 1000 permille = 1.0 ratio
|
||||
[InlineData(0, 0.0)] // No ratio
|
||||
public void Ratio_ReturnsCorrectValue(int ratioRaw, double expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { RatioRaw = ratioRaw };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Ratio;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3600, 3600L)] // 1 hour
|
||||
[InlineData(0, 0L)]
|
||||
[InlineData(-1, -1L)] // Unknown/infinite
|
||||
public void Eta_ReturnsCorrectValue(int eta, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { ETA = eta };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
public void SeedingTimeSeconds_WithCompletedDate_ReturnsPositiveValue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker1.example.com/announce\r\nudp://tracker1.example.com:1337/announce"
|
||||
};
|
||||
// Arrange - Set DateCompleted to 1 hour ago
|
||||
var oneHourAgo = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
|
||||
var torrentItem = new UTorrentItem { DateCompleted = oneHourAgo };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
// Assert - Should be approximately 3600 seconds (1 hour), allow some tolerance
|
||||
result.ShouldBeInRange(3599L, 3601L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
public void SeedingTimeSeconds_WithNoCompletedDate_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://valid.example.com/announce\r\ninvalid-url\r\n\r\n "
|
||||
};
|
||||
// Arrange - DateCompleted = 0 means not completed
|
||||
var torrentItem = new UTorrentItem { DateCompleted = 0 };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
result.ShouldBe(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
public void IsIgnored_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = ""
|
||||
};
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackerList_ReturnsEmptyList()
|
||||
public void IsIgnored_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties(); // Trackers defaults to empty string
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
var ignoredDownloads = new[] { "abc123" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingCategory_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent", Label = "test-category" };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
var ignoredDownloads = new[] { "test-category" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTracker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker.example.com/announce"
|
||||
};
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
var ignoredDownloads = new[] { "tracker.example.com" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_NotMatching_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent", Label = "some-category" };
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker.example.com/announce"
|
||||
};
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
var ignoredDownloads = new[] { "notmatching" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
{
|
||||
private readonly UTorrentServiceFixture _fixture;
|
||||
|
||||
public UTorrentServiceDCTests(UTorrentServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class GetSeedingDownloads_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public GetSeedingDownloads_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiltersSeedingTorrents()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new List<UTorrentItem>
|
||||
{
|
||||
new UTorrentItem { Hash = "hash1", Name = "Torrent 1", Status = 9, DateCompleted = 1000 }, // Seeding (Started + Checked, DateCompleted > 0)
|
||||
new UTorrentItem { Hash = "hash2", Name = "Torrent 2", Status = 9, DateCompleted = 0 }, // Downloading (Started + Checked, DateCompleted = 0)
|
||||
new UTorrentItem { Hash = "hash3", Name = "Torrent 3", Status = 9, DateCompleted = 2000 } // Seeding (Started + Checked, DateCompleted > 0)
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash3"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" });
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenNoSeedingTorrents()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new List<UTorrentItem>
|
||||
{
|
||||
new UTorrentItem { Hash = "hash1", Name = "Torrent 1", Status = 9 } // Not seeding
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new List<UTorrentItem>
|
||||
{
|
||||
new UTorrentItem { Hash = "", Name = "No Hash", Status = 9, DateCompleted = 1000 },
|
||||
new UTorrentItem { Hash = "hash1", Name = "Valid Hash", Status = 9, DateCompleted = 1000 }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToBeCleanedAsync_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToBeCleanedAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }),
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash2", Label = "tv" }, new UTorrentProperties { Hash = "hash2", Pex = 1, Trackers = "" }),
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash3", Label = "music" }, new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, x => x.Category == "movies");
|
||||
Assert.Contains(result, x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmptyList_WhenNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "music" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToChangeCategoryAsync_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToChangeCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }),
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash2", Label = "tv" }, new UTorrentProperties { Hash = "hash2", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsDownloadsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "", Label = "movies" }, new UTorrentProperties { Hash = "", Pex = 1, Trackers = "" }),
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCategoryAsync_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public CreateCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsNoOp()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("new-category");
|
||||
|
||||
// Assert - no exceptions thrown, no client calls made
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteDownload_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public DeleteDownload_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsClientDelete()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizesHashToLowercase()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "UPPERCASE-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsClientDeleteWithoutSourceFiles()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeCategoryForNoHardLinksAsync_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public ChangeCategoryForNoHardLinksAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingHash_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingName_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingCategory_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoHardlinks_ChangesLabel()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasHardlinks_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileNotFound_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkippedFiles_IgnoredInCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
|
||||
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithIgnoredRootDir_PopulatesFileCounts()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked",
|
||||
UnlinkedIgnoredRootDirs = ["/ignore"]
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishesCategoryChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - EventPublisher is not mocked, so we just verify the method completed
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullFilesResponse_ChangesLabel()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync((List<UTorrentFile>?)null);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - When files is null, it uses empty collection and proceeds to change label
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync("hash1", "unlinked"), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class UTorrentServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<UTorrentService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IUTorrentClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public UTorrentServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<UTorrentService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IUTorrentClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public UTorrentService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.uTorrent,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = "/gui/"
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new UTorrentService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
{
|
||||
private readonly UTorrentServiceFixture _fixture;
|
||||
|
||||
public UTorrentServiceTests(UTorrentServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_BasicScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentNotFound_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "nonexistent";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync((UTorrentItem?)null);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked = 1 + 8
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = -1, // -1 means private torrent
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked = 1 + 8
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1, // 1 means public torrent (PEX enabled)
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFilesUnwanted_DeletesFromClient()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
|
||||
new UTorrentFile { Name = "file2.mkv", Priority = 0, Index = 1, Size = 2000, Downloaded = 0 }
|
||||
});
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SomeFilesWanted_DoesNotRemove()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
|
||||
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string category = "test-category";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000,
|
||||
Label = category
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByTrackerDomain_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string trackerDomain = "tracker.example.com";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = $"https://{trackerDomain}/announce\r\n"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_ExceptionHandlingScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_ExceptionHandlingScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ThrowsException_ContinuesProcessing()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotDownloadingState_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 32, // Paused
|
||||
DownloadSpeed = 0
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked
|
||||
DownloadSpeed = 0
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked
|
||||
DownloadSpeed = 0,
|
||||
ETA = 0
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter.Consumers;
|
||||
|
||||
public class DownloadHunterConsumerTests
|
||||
{
|
||||
private readonly Mock<ILogger<DownloadHunterConsumer<SearchItem>>> _loggerMock;
|
||||
private readonly Mock<IDownloadHunter> _downloadHunterMock;
|
||||
private readonly DownloadHunterConsumer<SearchItem> _consumer;
|
||||
|
||||
public DownloadHunterConsumerTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DownloadHunterConsumer<SearchItem>>>();
|
||||
_downloadHunterMock = new Mock<IDownloadHunter>();
|
||||
_consumer = new DownloadHunterConsumer<SearchItem>(_loggerMock.Object, _downloadHunterMock.Object);
|
||||
}
|
||||
|
||||
#region Consume Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_CallsHuntDownloadsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(request), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WhenHunterThrows_LogsErrorAndDoesNotRethrow()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.ThrowsAsync(new Exception("Hunt failed"));
|
||||
|
||||
// Act - Should not throw
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to search for replacement")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_PassesCorrectRequestToHunter()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
DownloadHuntRequest<SearchItem>? capturedRequest = null;
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Callback<DownloadHuntRequest<SearchItem>>(r => capturedRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Lidarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 999 },
|
||||
Record = CreateQueueRecord()
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(
|
||||
It.Is<DownloadHuntRequest<SearchItem>>(r => r.InstanceType == InstanceType.Lidarr)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static DownloadHuntRequest<SearchItem> CreateHuntRequest()
|
||||
{
|
||||
return new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord()
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://radarr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123"
|
||||
};
|
||||
}
|
||||
|
||||
private static Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>> CreateConsumeContextMock(DownloadHuntRequest<SearchItem> message)
|
||||
{
|
||||
var mock = new Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>>();
|
||||
mock.Setup(c => c.Message).Returns(message);
|
||||
return mock;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter;
|
||||
|
||||
public class DownloadHunterTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
|
||||
private readonly Mock<IArrClient> _arrClientMock;
|
||||
private readonly FakeTimeProvider _fakeTimeProvider;
|
||||
private readonly Infrastructure.Features.DownloadHunter.DownloadHunter _downloadHunter;
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public DownloadHunterTests()
|
||||
{
|
||||
// Use SQLite in-memory with shared connection to support complex types
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_dataContext = new DataContext(options);
|
||||
_dataContext.Database.EnsureCreated();
|
||||
|
||||
_arrClientFactoryMock = new Mock<IArrClientFactory>();
|
||||
_arrClientMock = new Mock<IArrClient>();
|
||||
_fakeTimeProvider = new FakeTimeProvider();
|
||||
|
||||
_arrClientFactoryMock
|
||||
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(_arrClientMock.Object);
|
||||
|
||||
_downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter(
|
||||
_dataContext,
|
||||
_arrClientFactoryMock.Object,
|
||||
_fakeTimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
#region HuntDownloadsAsync - Search Disabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchDisabled_DoesNotCallArrClient()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: false);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
await _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>()), Times.Never);
|
||||
_arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchDisabled_ReturnsImmediately()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: false);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Should complete without needing to advance time
|
||||
var completedTask = await Task.WhenAny(task, Task.Delay(100));
|
||||
Assert.Same(task, completedTask);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HuntDownloadsAsync - Search Enabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsArrClientFactory()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act - Start the task and advance time
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsSearchItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(
|
||||
c => c.SearchItemsAsync(
|
||||
request.Instance,
|
||||
It.Is<HashSet<SearchItem>>(s => s.Contains(request.SearchItem))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public async Task HuntDownloadsAsync_UsesCorrectInstanceType(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest(instanceType);
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HuntDownloadsAsync - Delay Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WaitsForConfiguredDelay()
|
||||
{
|
||||
// Arrange
|
||||
const ushort configuredDelay = 120;
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: configuredDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Task should not complete before advancing time
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance partial time - should still not complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(configuredDelay - 1));
|
||||
await Task.Delay(10); // Give the task a chance to complete if it would
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance remaining time - should now complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayBelowMinimum_UsesDefaultDelay()
|
||||
{
|
||||
// Arrange - Set delay below minimum (simulating manual DB edit)
|
||||
const ushort belowMinDelay = 10; // Below MinSearchDelaySeconds (60)
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: belowMinDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by the below-min value - should NOT complete because it should use default
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(belowMinDelay));
|
||||
await Task.Delay(10);
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance to default delay - should now complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds - belowMinDelay));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayIsZero_UsesDefaultDelay()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: 0);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Should not complete immediately
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance to default delay
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayAtMinimum_UsesConfiguredDelay()
|
||||
{
|
||||
// Arrange - Set delay exactly at minimum
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by minimum - should complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayAboveMinimum_UsesConfiguredDelay()
|
||||
{
|
||||
// Arrange - Set delay above minimum
|
||||
const ushort aboveMinDelay = 180;
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: aboveMinDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by minimum - should NOT complete yet
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await Task.Delay(10);
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance remaining time
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(aboveMinDelay - Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task SetupGeneralConfig(bool searchEnabled, ushort searchDelay = Constants.DefaultSearchDelaySeconds)
|
||||
{
|
||||
var generalConfig = new GeneralConfig
|
||||
{
|
||||
SearchEnabled = searchEnabled,
|
||||
SearchDelay = searchDelay
|
||||
};
|
||||
|
||||
_dataContext.GeneralConfigs.Add(generalConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static DownloadHuntRequest<SearchItem> CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr)
|
||||
{
|
||||
return new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord()
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://arr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover.Consumers;
|
||||
|
||||
public class DownloadRemoverConsumerTests
|
||||
{
|
||||
private readonly Mock<ILogger<DownloadRemoverConsumer<SearchItem>>> _loggerMock;
|
||||
private readonly Mock<IQueueItemRemover> _queueItemRemoverMock;
|
||||
private readonly DownloadRemoverConsumer<SearchItem> _consumer;
|
||||
|
||||
public DownloadRemoverConsumerTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DownloadRemoverConsumer<SearchItem>>>();
|
||||
_queueItemRemoverMock = new Mock<IQueueItemRemover>();
|
||||
_consumer = new DownloadRemoverConsumer<SearchItem>(_loggerMock.Object, _queueItemRemoverMock.Object);
|
||||
}
|
||||
|
||||
#region Consume Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_CallsRemoveQueueItemAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(request), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WhenRemoverThrows_LogsErrorAndDoesNotRethrow()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.ThrowsAsync(new Exception("Remove failed"));
|
||||
|
||||
// Act - Should not throw
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to remove queue item")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_PassesCorrectRequestToRemover()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
QueueItemRemoveRequest<SearchItem>? capturedRequest = null;
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Callback<QueueItemRemoveRequest<SearchItem>>(r => capturedRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
Assert.Equal(request.RemoveFromClient, capturedRequest.RemoveFromClient);
|
||||
Assert.Equal(request.DeleteReason, capturedRequest.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithRemoveFromClientTrue_PassesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Sonarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 456 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.Stalled
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(req =>
|
||||
req.RemoveFromClient == true &&
|
||||
req.DeleteReason == DeleteReason.Stalled)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithDifferentDeleteReasons_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 789 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = false,
|
||||
DeleteReason = DeleteReason.FailedImport
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(req =>
|
||||
req.DeleteReason == DeleteReason.FailedImport)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Readarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 111 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.SlowSpeed
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(req => req.InstanceType == InstanceType.Readarr)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest()
|
||||
{
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.Stalled
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://radarr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123"
|
||||
};
|
||||
}
|
||||
|
||||
private static Mock<ConsumeContext<QueueItemRemoveRequest<SearchItem>>> CreateConsumeContextMock(QueueItemRemoveRequest<SearchItem> message)
|
||||
{
|
||||
var mock = new Mock<ConsumeContext<QueueItemRemoveRequest<SearchItem>>>();
|
||||
mock.Setup(c => c.Message).Returns(message);
|
||||
return mock;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
|
||||
|
||||
public class QueueItemRemoverTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
|
||||
private readonly Mock<IBus> _busMock;
|
||||
private readonly MemoryCache _memoryCache;
|
||||
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
|
||||
private readonly Mock<IArrClient> _arrClientMock;
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly QueueItemRemover _queueItemRemover;
|
||||
|
||||
public QueueItemRemoverTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
|
||||
_busMock = new Mock<IBus>();
|
||||
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
|
||||
_arrClientFactoryMock = new Mock<IArrClientFactory>();
|
||||
_arrClientMock = new Mock<IArrClient>();
|
||||
|
||||
_arrClientFactoryMock
|
||||
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(_arrClientMock.Object);
|
||||
|
||||
// Create real EventPublisher with mocked dependencies
|
||||
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_eventsContext = new EventsContext(eventsContextOptions);
|
||||
|
||||
var hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
|
||||
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
// Setup interceptor to execute the action with params using DynamicInvoke
|
||||
dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
var result = action.DynamicInvoke(parameters);
|
||||
if (result is Task task)
|
||||
{
|
||||
return task;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
_eventPublisher = new EventPublisher(
|
||||
_eventsContext,
|
||||
hubContextMock.Object,
|
||||
Mock.Of<ILogger<EventPublisher>>(),
|
||||
Mock.Of<INotificationPublisher>(),
|
||||
dryRunInterceptorMock.Object);
|
||||
|
||||
_queueItemRemover = new QueueItemRemover(
|
||||
_loggerMock.Object,
|
||||
_busMock.Object,
|
||||
_memoryCache,
|
||||
_arrClientFactoryMock.Object,
|
||||
_eventPublisher
|
||||
);
|
||||
|
||||
// Clear static RecurringHashes before each test
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_memoryCache.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
#region RemoveQueueItemAsync - Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Success_DeletesQueueItem()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
request.Instance,
|
||||
request.Record,
|
||||
request.RemoveFromClient,
|
||||
request.DeleteReason), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
DownloadHuntRequest<SearchItem>? capturedRequest = null;
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_busMock
|
||||
.Setup(b => b.Publish(It.IsAny<DownloadHuntRequest<SearchItem>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<DownloadHuntRequest<SearchItem>, CancellationToken>((r, _) => capturedRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest!.InstanceType);
|
||||
Assert.Equal(request.Instance, capturedRequest.Instance);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Success_ClearsDownloadMarkedForRemovalCache()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
|
||||
_memoryCache.Set(cacheKey, true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public async Task RemoveQueueItemAsync_UsesCorrectClientForInstanceType(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(instanceType);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - Recurring Hash Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
Striker.RecurringHashes.TryAdd(hash, null);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_RemovesHashFromRecurring()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
Striker.RecurringHashes.TryAdd(hash, null);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - HTTP Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenNotFoundError_ThrowsWithItemAlreadyDeletedMessage()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<Exception>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Contains("might have already been deleted", exception.Message);
|
||||
Assert.Contains(request.InstanceType.ToString(), exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenNotFoundError_ClearsCacheInFinally()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
|
||||
_memoryCache.Set(cacheKey, true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
// Cache should be cleared in finally block
|
||||
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenOtherHttpError_Rethrows()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var originalException = new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.ThrowsAsync(originalException);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Same(originalException, exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenNonHttpError_Rethrows()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var originalException = new InvalidOperationException("Some other error");
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.ThrowsAsync(originalException);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Same(originalException, exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - Delete Reason Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(DeleteReason.Stalled)]
|
||||
[InlineData(DeleteReason.FailedImport)]
|
||||
[InlineData(DeleteReason.SlowSpeed)]
|
||||
[InlineData(DeleteReason.SlowTime)]
|
||||
[InlineData(DeleteReason.DownloadingMetadata)]
|
||||
[InlineData(DeleteReason.MalwareFileFound)]
|
||||
public async Task RemoveQueueItemAsync_PassesCorrectDeleteReason(DeleteReason deleteReason)
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(deleteReason: deleteReason);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
deleteReason), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RemoveQueueItemAsync_PassesCorrectRemoveFromClientFlag(bool removeFromClient)
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(removeFromClient: removeFromClient);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
removeFromClient,
|
||||
It.IsAny<DeleteReason>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
|
||||
InstanceType instanceType = InstanceType.Sonarr,
|
||||
bool removeFromClient = true,
|
||||
DeleteReason deleteReason = DeleteReason.Stalled)
|
||||
{
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://arr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123DEF456"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
|
||||
|
||||
[Collection(JobHandlerCollection.Name)]
|
||||
public class DownloadCleanerTests : IDisposable
|
||||
{
|
||||
private readonly JobHandlerFixture _fixture;
|
||||
private readonly Mock<ILogger<DownloadCleaner>> _logger;
|
||||
|
||||
public DownloadCleanerTests(JobHandlerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.RecreateDataContext();
|
||||
_fixture.ResetMocks();
|
||||
_logger = _fixture.CreateLogger<DownloadCleaner>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private DownloadCleaner CreateSut()
|
||||
{
|
||||
return new DownloadCleaner(
|
||||
_logger.Object,
|
||||
_fixture.DataContext,
|
||||
_fixture.Cache,
|
||||
_fixture.MessageBus.Object,
|
||||
_fixture.ArrClientFactory.Object,
|
||||
_fixture.ArrQueueIterator.Object,
|
||||
_fixture.DownloadServiceFactory.Object,
|
||||
_fixture.EventPublisher.Object,
|
||||
_fixture.TimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
#region ExecuteAsync Tests (inherited from GenericHandler)
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_LoadsAllConfigsIntoContextProvider()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - verify configs were loaded (by checking the handler completed without errors)
|
||||
// The configs are loaded into ContextProvider which is AsyncLocal scoped
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no download clients")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteInternalAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no download clients are configured")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoFeaturesEnabled_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - should warn about no seeding downloads or no features enabled
|
||||
// The exact message depends on the order of checks
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
It.IsAny<LogLevel>(),
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.AtLeastOnce
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoSeedingDownloadsFound_LogsInfoAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No seeding downloads found")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_FiltersOutIgnoredDownloads()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
// Add ignored download to general config
|
||||
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
|
||||
generalConfig.IgnoredDownloads = ["ignored-hash"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("ignored-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Ignored Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(true);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert - the download should be skipped
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("download is ignored")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_FiltersOutDownloadsUsedByArrs()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("arr-download-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Arr Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
// Setup arr client to return queue record with matching download ID
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "arr-download-hash",
|
||||
Title = "Test Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert - the download should be skipped because it's used by an arr
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("download is used by an arr")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_ProcessesAllArrConfigs()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
// Need at least one download for arr processing to occur
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert - both instances should be processed
|
||||
_fixture.ArrClientFactory.Verify(
|
||||
x => x.GetClient(InstanceType.Sonarr),
|
||||
Times.Once
|
||||
);
|
||||
_fixture.ArrClientFactory.Verify(
|
||||
x => x.GetClient(InstanceType.Radarr),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChangeUnlinkedCategoriesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenUnlinkedEnabled_EvaluatesDownloadsForHardlinks()
|
||||
{
|
||||
// Arrange
|
||||
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
|
||||
downloadCleanerConfig.UnlinkedEnabled = true;
|
||||
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
|
||||
downloadCleanerConfig.UnlinkedCategories = ["completed"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockDownloadService
|
||||
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("hardlinks")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CleanDownloadsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenCategoriesConfigured_EvaluatesDownloadsForCleaning()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext, "completed", 1.0, 60);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CleanDownloadsAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("cleanup")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ProcessInstanceAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_CollectsDownloadIdsFromArrQueue()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
// Need at least one download for arr processing to occur
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecords = new List<QueueRecord>
|
||||
{
|
||||
new() { Id = 1, DownloadId = "hash1", Title = "Download 1", Protocol = "torrent" },
|
||||
new() { Id = 2, DownloadId = "hash2", Title = "Download 2", Protocol = "torrent" }
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
mockArrClient.Object,
|
||||
It.Is<ArrInstance>(i => i.Id == sonarrInstance.Id),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback(queueRecords);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert - verify the iterator was called
|
||||
_fixture.ArrQueueIterator.Verify(
|
||||
x => x.Iterate(
|
||||
mockArrClient.Object,
|
||||
It.Is<ArrInstance>(i => i.Id == sonarrInstance.Id),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenDownloadServiceFails_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Failing Client");
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Working Client");
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var failingService = _fixture.CreateMockDownloadService("Failing Client");
|
||||
failingService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ThrowsAsync(new Exception("Connection failed"));
|
||||
|
||||
var workingService = _fixture.CreateMockDownloadService("Working Client");
|
||||
workingService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var callCount = 0;
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
callCount++;
|
||||
return callCount == 1 ? failingService.Object : workingService.Object;
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to get seeding downloads")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeUnlinkedCategoriesAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
|
||||
downloadCleanerConfig.UnlinkedEnabled = true;
|
||||
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
|
||||
downloadCleanerConfig.UnlinkedCategories = ["completed"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.Throws(new Exception("Filter failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for hardlinks evaluation")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeUnlinkedCategoriesAsync_WhenCreateCategoryThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
|
||||
downloadCleanerConfig.UnlinkedEnabled = true;
|
||||
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
|
||||
downloadCleanerConfig.UnlinkedCategories = ["completed"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new Exception("Create category failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to create category")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeUnlinkedCategoriesAsync_WhenChangeCategoryThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
|
||||
downloadCleanerConfig.UnlinkedEnabled = true;
|
||||
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
|
||||
downloadCleanerConfig.UnlinkedCategories = ["completed"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockDownloadService
|
||||
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
|
||||
.ThrowsAsync(new Exception("Change category failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to change category for download client")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanDownloadsAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Throws(new Exception("Filter failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for cleaning")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanDownloadsAsync_WhenCleanDownloadsThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CleanDownloadsAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.ThrowsAsync(new Exception("Clean failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to clean downloads for download client")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessArrConfigAsync_WhenArrIteratorThrows_LogsErrorAndRethrows()
|
||||
{
|
||||
// Arrange - DownloadCleaner calls ProcessArrConfigAsync with throwOnFailure=true
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
// Make the arr queue iterator throw an exception
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.ThrowsAsync(new InvalidOperationException("Arr connection failed"));
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act & Assert - exception should propagate since throwOnFailure=true
|
||||
// Need to advance time for the delay to pass before the exception is thrown
|
||||
var task = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => task);
|
||||
Assert.Equal("Arr connection failed", exception.Message);
|
||||
|
||||
// Verify error was logged
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to process")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using MalwareBlockerJob = Cleanuparr.Infrastructure.Features.Jobs.MalwareBlocker;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
|
||||
|
||||
[Collection(JobHandlerCollection.Name)]
|
||||
public class MalwareBlockerTests : IDisposable
|
||||
{
|
||||
private readonly JobHandlerFixture _fixture;
|
||||
private readonly Mock<ILogger<MalwareBlockerJob>> _logger;
|
||||
|
||||
public MalwareBlockerTests(JobHandlerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.RecreateDataContext();
|
||||
_fixture.ResetMocks();
|
||||
_logger = _fixture.CreateLogger<MalwareBlockerJob>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private MalwareBlockerJob CreateSut()
|
||||
{
|
||||
return new MalwareBlockerJob(
|
||||
_logger.Object,
|
||||
_fixture.DataContext,
|
||||
_fixture.Cache,
|
||||
_fixture.MessageBus.Object,
|
||||
_fixture.ArrClientFactory.Object,
|
||||
_fixture.ArrQueueIterator.Object,
|
||||
_fixture.DownloadServiceFactory.Object,
|
||||
_fixture.BlocklistProvider.Object,
|
||||
_fixture.EventPublisher.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region ExecuteInternalAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No download clients configured")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoBlocklistsEnabled_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No blocklists are enabled")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenBlocklistEnabled_LoadsBlocklists()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_fixture.BlocklistProvider.Verify(x => x.LoadBlocklistsAsync(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenSonarrEnabled_ProcessesSonarrInstances()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenDeleteKnownMalwareEnabled_ProcessesAllArrs()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.DeleteKnownMalware = true;
|
||||
// Need at least one blocklist enabled for processing to occur
|
||||
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - Sonarr and Radarr processed because DeleteKnownMalware is true
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ProcessInstanceAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_SkipsIgnoredDownloads()
|
||||
{
|
||||
// Arrange
|
||||
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
|
||||
generalConfig.IgnoredDownloads = ["ignored-download-id"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "ignored-download-id",
|
||||
Title = "Ignored Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ignored")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_ChecksTorrentClientsForBlockedFiles()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "torrent-download-id",
|
||||
Title = "Torrent Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult { Found = true, ShouldRemove = false });
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
mockDownloadService.Verify(
|
||||
x => x.BlockUnwantedFilesAsync("torrent-download-id", It.IsAny<List<string>>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenShouldRemove_PublishesRemoveRequest()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "malware-download-id",
|
||||
Title = "Malware Download",
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.DeleteReason == DeleteReason.AllFilesBlocked
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenPrivateAndDeletePrivateFalse_DoesNotRemoveFromClient()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
// Ensure DeletePrivate is false
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.DeletePrivate = false;
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "private-malware-id",
|
||||
Title = "Private Malware",
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = true,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - RemoveFromClient should be false
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.RemoveFromClient == false
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenDownloadNotFoundInTorrentClient_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "missing-download-id",
|
||||
Title = "Missing Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult { Found = false });
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Download not found in any torrent client")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenDownloadServiceThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "error-download-id",
|
||||
Title = "Error Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ThrowsAsync(new Exception("Connection failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error checking download")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void EnableSonarrBlocklist()
|
||||
{
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
|
||||
_fixture.DataContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for job handler tests that share <see cref="JobHandlerFixture"/>.
|
||||
/// Tests in this collection run sequentially to avoid FakeTimeProvider interference.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public class JobHandlerCollection : ICollectionFixture<JobHandlerFixture>
|
||||
{
|
||||
public const string Name = "JobHandler";
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Persistence;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Base fixture for job handler tests providing common mock dependencies
|
||||
/// </summary>
|
||||
public class JobHandlerFixture : IDisposable
|
||||
{
|
||||
public DataContext DataContext { get; private set; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IBus> MessageBus { get; }
|
||||
public Mock<IArrClientFactory> ArrClientFactory { get; }
|
||||
public Mock<IArrQueueIterator> ArrQueueIterator { get; }
|
||||
public Mock<IDownloadServiceFactory> DownloadServiceFactory { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public Mock<IBlocklistProvider> BlocklistProvider { get; }
|
||||
public FakeTimeProvider TimeProvider { get; private set; }
|
||||
|
||||
public JobHandlerFixture()
|
||||
{
|
||||
DataContext = TestDataContextFactory.Create();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
MessageBus = new Mock<IBus>();
|
||||
ArrClientFactory = new Mock<IArrClientFactory>();
|
||||
ArrQueueIterator = new Mock<IArrQueueIterator>();
|
||||
DownloadServiceFactory = new Mock<IDownloadServiceFactory>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = new Mock<IBlocklistProvider>();
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
|
||||
// Setup default behaviors
|
||||
SetupDefaultBehaviors();
|
||||
}
|
||||
|
||||
private void SetupDefaultBehaviors()
|
||||
{
|
||||
// EventPublisher methods return completed task by default
|
||||
EventPublisher
|
||||
.Setup(x => x.PublishAsync(
|
||||
It.IsAny<Domain.Enums.EventType>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Domain.Enums.EventSeverity>(),
|
||||
It.IsAny<object?>(),
|
||||
It.IsAny<Guid?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock logger for a specific handler type
|
||||
/// </summary>
|
||||
public Mock<ILogger<T>> CreateLogger<T>() where T : GenericHandler
|
||||
{
|
||||
return new Mock<ILogger<T>>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock download service
|
||||
/// </summary>
|
||||
public Mock<IDownloadService> CreateMockDownloadService(string clientName = "Test Client")
|
||||
{
|
||||
var mock = new Mock<IDownloadService>();
|
||||
mock.Setup(x => x.ClientConfig).Returns(new Persistence.Models.Configuration.DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = clientName,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.qBittorrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080")
|
||||
});
|
||||
mock.Setup(x => x.LoginAsync()).Returns(Task.CompletedTask);
|
||||
return mock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the DownloadServiceFactory to return the specified mock services
|
||||
/// </summary>
|
||||
public void SetupDownloadServices(params Mock<IDownloadService>[] services)
|
||||
{
|
||||
foreach (var service in services)
|
||||
{
|
||||
DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(service.Object.ClientConfig))
|
||||
.Returns(service.Object);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fresh DataContext, disposing the old one
|
||||
/// </summary>
|
||||
public DataContext RecreateDataContext(bool seedData = true)
|
||||
{
|
||||
DataContext?.Dispose();
|
||||
DataContext = TestDataContextFactory.Create(seedData);
|
||||
return DataContext;
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
MessageBus.Reset();
|
||||
ArrClientFactory.Reset();
|
||||
ArrQueueIterator.Reset();
|
||||
DownloadServiceFactory.Reset();
|
||||
EventPublisher.Reset();
|
||||
BlocklistProvider.Reset();
|
||||
Cache.Clear();
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
|
||||
SetupDefaultBehaviors();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DataContext?.Dispose();
|
||||
Cache?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory DataContext instances for testing
|
||||
/// </summary>
|
||||
public static class TestDataContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new SQLite in-memory DataContext with default seed data
|
||||
/// </summary>
|
||||
public static DataContext Create(bool seedData = true)
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
if (seedData)
|
||||
{
|
||||
SeedDefaultData(context);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the minimum required data for GenericHandler.ExecuteAsync() to work
|
||||
/// </summary>
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
// General config
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
// Arr configs for all instance types
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
// Queue cleaner config
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
// Content blocker config
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeleteKnownMalware = false,
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
// Download cleaner config
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
Categories = [],
|
||||
UnlinkedEnabled = false,
|
||||
UnlinkedTargetCategory = "",
|
||||
UnlinkedCategories = []
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Sonarr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddSonarrInstance(DataContext context, string url = "http://sonarr:8989", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Sonarr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Radarr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddRadarrInstance(DataContext context, string url = "http://radarr:7878", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Radarr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Lidarr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddLidarrInstance(DataContext context, string url = "http://lidarr:8686", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Lidarr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Readarr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddReadarrInstance(DataContext context, string url = "http://readarr:8787", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Readarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Readarr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Whisparr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddWhisparrInstance(DataContext context, string url = "http://whisparr:6969", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Whisparr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Whisparr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled download client to the context
|
||||
/// </summary>
|
||||
public static DownloadClientConfig AddDownloadClient(
|
||||
DataContext context,
|
||||
string name = "Test qBittorrent",
|
||||
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
|
||||
bool enabled = true)
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = enabled,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin"
|
||||
};
|
||||
|
||||
context.DownloadClients.Add(config);
|
||||
context.SaveChanges();
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stall rule to the context
|
||||
/// </summary>
|
||||
public static StallRule AddStallRule(
|
||||
DataContext context,
|
||||
string name = "Test Stall Rule",
|
||||
bool enabled = true,
|
||||
ushort minCompletionPercentage = 0,
|
||||
ushort maxCompletionPercentage = 100,
|
||||
int maxStrikes = 3)
|
||||
{
|
||||
var queueCleanerConfig = context.QueueCleanerConfigs.First();
|
||||
var rule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = enabled,
|
||||
MinCompletionPercentage = minCompletionPercentage,
|
||||
MaxCompletionPercentage = maxCompletionPercentage,
|
||||
MaxStrikes = maxStrikes,
|
||||
QueueCleanerConfigId = queueCleanerConfig.Id
|
||||
};
|
||||
|
||||
context.StallRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a slow rule to the context
|
||||
/// </summary>
|
||||
public static SlowRule AddSlowRule(
|
||||
DataContext context,
|
||||
string name = "Test Slow Rule",
|
||||
bool enabled = true,
|
||||
ushort minCompletionPercentage = 0,
|
||||
ushort maxCompletionPercentage = 100,
|
||||
int maxStrikes = 3,
|
||||
string minSpeed = "1 KB/s")
|
||||
{
|
||||
var queueCleanerConfig = context.QueueCleanerConfigs.First();
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = enabled,
|
||||
MinCompletionPercentage = minCompletionPercentage,
|
||||
MaxCompletionPercentage = maxCompletionPercentage,
|
||||
MaxStrikes = maxStrikes,
|
||||
MinSpeed = minSpeed,
|
||||
QueueCleanerConfigId = queueCleanerConfig.Id
|
||||
};
|
||||
|
||||
context.SlowRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a clean category to the download cleaner config
|
||||
/// </summary>
|
||||
public static SeedingRule AddSeedingRule(
|
||||
DataContext context,
|
||||
string name = "completed",
|
||||
double maxRatio = 1.0,
|
||||
double minSeedTime = 1.0,
|
||||
double maxSeedTime = -1)
|
||||
{
|
||||
var config = context.DownloadCleanerConfigs.Include(x => x.Categories).First();
|
||||
var category = new SeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
DeleteSourceFiles = true,
|
||||
DownloadCleanerConfigId = config.Id
|
||||
};
|
||||
|
||||
config.Categories.Add(category);
|
||||
context.SeedingRules.Add(category);
|
||||
context.SaveChanges();
|
||||
|
||||
return category;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.MalwareBlocker;
|
||||
|
||||
public class BlocklistProviderTests : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly BlocklistProvider _provider;
|
||||
|
||||
public BlocklistProviderTests()
|
||||
{
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_logger = Substitute.For<ILogger<BlocklistProvider>>();
|
||||
_scopeFactory = Substitute.For<IServiceScopeFactory>();
|
||||
|
||||
_provider = new BlocklistProvider(_logger, _scopeFactory, _cache);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBlocklistType_NotInCache_ReturnsDefaultBlacklist()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetBlocklistType(InstanceType.Sonarr);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(BlocklistType.Blacklist);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public void GetBlocklistType_InCache_ReturnsCachedValue(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
_cache.Set(CacheKeys.BlocklistType(instanceType), BlocklistType.Whitelist);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetBlocklistType(instanceType);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(BlocklistType.Whitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPatterns_NotInCache_ReturnsEmptyBag()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetPatterns(InstanceType.Sonarr);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPatterns_InCache_ReturnsCachedPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new ConcurrentBag<string> { "*.exe", "*.dll", "malware*" };
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Radarr), patterns);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetPatterns(InstanceType.Radarr);
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("*.exe");
|
||||
result.ShouldContain("*.dll");
|
||||
result.ShouldContain("malware*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRegexes_NotInCache_ReturnsEmptyBag()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetRegexes(InstanceType.Lidarr);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRegexes_InCache_ReturnsCachedRegexes()
|
||||
{
|
||||
// Arrange
|
||||
var regexes = new ConcurrentBag<Regex>
|
||||
{
|
||||
new Regex(@"^\d+$"),
|
||||
new Regex(@"test\d+\.exe")
|
||||
};
|
||||
_cache.Set(CacheKeys.BlocklistRegexes(InstanceType.Readarr), regexes);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetRegexes(InstanceType.Readarr);
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMalwarePatterns_NotInCache_ReturnsEmptyBag()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetMalwarePatterns();
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMalwarePatterns_InCache_ReturnsCachedPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new ConcurrentBag<string> { "known_malware.exe", "trojan*", "virus.dll" };
|
||||
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetMalwarePatterns();
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("known_malware.exe");
|
||||
result.ShouldContain("trojan*");
|
||||
result.ShouldContain("virus.dll");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public void GetPatterns_DifferentInstanceTypes_UsesCorrectCacheKey(InstanceType instanceType)
|
||||
{
|
||||
// Arrange - set patterns for each instance type differently
|
||||
var patterns = new ConcurrentBag<string> { $"pattern_for_{instanceType}" };
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetPatterns(instanceType);
|
||||
|
||||
// Assert
|
||||
result.ShouldContain($"pattern_for_{instanceType}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPatterns_DifferentInstanceTypes_ReturnsDifferentPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var sonarrPatterns = new ConcurrentBag<string> { "sonarr_pattern" };
|
||||
var radarrPatterns = new ConcurrentBag<string> { "radarr_pattern" };
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Sonarr), sonarrPatterns);
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Radarr), radarrPatterns);
|
||||
|
||||
// Act
|
||||
var sonarrResult = _provider.GetPatterns(InstanceType.Sonarr);
|
||||
var radarrResult = _provider.GetPatterns(InstanceType.Radarr);
|
||||
|
||||
// Assert
|
||||
sonarrResult.ShouldContain("sonarr_pattern");
|
||||
sonarrResult.ShouldNotContain("radarr_pattern");
|
||||
radarrResult.ShouldContain("radarr_pattern");
|
||||
radarrResult.ShouldNotContain("sonarr_pattern");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise;
|
||||
|
||||
public class AppriseProxyTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public AppriseProxyTests()
|
||||
{
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private AppriseProxy CreateProxy()
|
||||
{
|
||||
return new AppriseProxy(_httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static ApprisePayload CreatePayload()
|
||||
{
|
||||
return new ApprisePayload
|
||||
{
|
||||
Title = "Test Title",
|
||||
Body = "Test Body"
|
||||
};
|
||||
}
|
||||
|
||||
private static AppriseConfig CreateConfig()
|
||||
{
|
||||
return new AppriseConfig
|
||||
{
|
||||
Url = "http://apprise.local",
|
||||
Key = "test-key"
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidFactory_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_BuildsCorrectUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new AppriseConfig { Url = "http://apprise.local", Key = "my-key" };
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("/notify/my-key", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SetsJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithBasicAuth_SetsAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedAuthHeader = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedAuthHeader = req.Headers.Authorization?.Scheme)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new AppriseConfig { Url = "http://user:pass@apprise.local", Key = "test-key" };
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Basic", capturedAuthHeader);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsAppriseExceptionWithInvalidApiKey()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("API key is invalid", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When424_ThrowsAppriseExceptionWithTagsError()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse((HttpStatusCode)424);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("tags", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public async Task SendNotification_WhenServiceUnavailable_ThrowsAppriseException(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsAppriseException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unable to send notification", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsAppriseException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unable to send notification", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class AppriseProviderTests
|
||||
{
|
||||
private readonly Mock<IAppriseProxy> _proxyMock;
|
||||
private readonly AppriseConfig _config;
|
||||
private readonly AppriseProvider _provider;
|
||||
|
||||
public AppriseProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<IAppriseProxy>();
|
||||
_config = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://apprise.example.com",
|
||||
Key = "testkey",
|
||||
Tags = "tag1,tag2"
|
||||
};
|
||||
|
||||
_provider = new AppriseProvider(
|
||||
"TestApprise",
|
||||
NotificationProviderType.Apprise,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestApprise", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Apprise, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(context.Title, capturedPayload.Title);
|
||||
Assert.Contains(context.Description, capturedPayload.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataInBody()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("TestKey: TestValue", capturedPayload.Body);
|
||||
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Body);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EventSeverity.Information, "info")]
|
||||
[InlineData(EventSeverity.Warning, "warning")]
|
||||
[InlineData(EventSeverity.Important, "failure")]
|
||||
public async Task SendNotificationAsync_MapsEventSeverityToCorrectType(EventSeverity severity, string expectedType)
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = severity,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(expectedType, capturedPayload.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesTagsFromConfig()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("tag1,tag2", capturedPayload.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.ThrowsAsync(new Exception("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_StillIncludesDescription()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("Test Description", capturedPayload.Body);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Notifiarr;
|
||||
|
||||
public class NotifiarrProxyTests
|
||||
{
|
||||
private readonly Mock<ILogger<NotifiarrProxy>> _loggerMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public NotifiarrProxyTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<NotifiarrProxy>>();
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private NotifiarrProxy CreateProxy()
|
||||
{
|
||||
return new NotifiarrProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static NotifiarrPayload CreatePayload()
|
||||
{
|
||||
return new NotifiarrPayload
|
||||
{
|
||||
Notification = new NotifiarrNotification { Update = false },
|
||||
Discord = new Discord
|
||||
{
|
||||
Color = "#FF0000",
|
||||
Text = new Text { Title = "Test", Content = "Test content" },
|
||||
Ids = new Ids { Channel = "123456789" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifiarrConfig CreateConfig()
|
||||
{
|
||||
return new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "test-api-key-12345",
|
||||
ChannelId = "123456789"
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_BuildsCorrectUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new NotifiarrConfig { ApiKey = "my-api-key", ChannelId = "123" };
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("notifiarr.com", capturedUri.ToString());
|
||||
Assert.Contains("passthrough", capturedUri.ToString());
|
||||
Assert.Contains("my-api-key", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SetsJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_LogsTraceWithContent()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsNotifiarrExceptionWithInvalidApiKey()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("API key is invalid", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public async Task SendNotification_WhenServiceUnavailable_ThrowsNotifiarrException(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsNotifiarrException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsNotifiarrException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotifiarrProviderTests
|
||||
{
|
||||
private readonly Mock<INotifiarrProxy> _proxyMock;
|
||||
private readonly NotifiarrConfig _config;
|
||||
private readonly NotifiarrProvider _provider;
|
||||
|
||||
public NotifiarrProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<INotifiarrProxy>();
|
||||
_config = new NotifiarrConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiKey = "testapikey1234567890",
|
||||
ChannelId = "123456789012345678"
|
||||
};
|
||||
|
||||
_provider = new NotifiarrProvider(
|
||||
"TestNotifiarr",
|
||||
NotificationProviderType.Notifiarr,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestNotifiarr", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Notifiarr, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.NotNull(capturedPayload.Discord);
|
||||
Assert.Equal(context.Title, capturedPayload.Discord.Text.Title);
|
||||
Assert.Equal(context.Description, capturedPayload.Discord.Text.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_UsesConfiguredChannelId()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("123456789012345678", capturedPayload.Discord.Ids.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataAsFields()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(2, capturedPayload.Discord.Text.Fields.Count);
|
||||
Assert.Contains(capturedPayload.Discord.Text.Fields, f => f.Title == "TestKey" && f.Text == "TestValue");
|
||||
Assert.Contains(capturedPayload.Discord.Text.Fields, f => f.Title == "AnotherKey" && f.Text == "AnotherValue");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EventSeverity.Information, "28a745")] // Green
|
||||
[InlineData(EventSeverity.Warning, "f0ad4e")] // Orange
|
||||
[InlineData(EventSeverity.Important, "bb2124")] // Red
|
||||
public async Task SendNotificationAsync_MapsEventSeverityToCorrectColor(EventSeverity severity, string expectedColor)
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = severity,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(expectedColor, capturedPayload.Discord.Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesCleanuperrLogo()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("Cleanuparr", capturedPayload.Discord.Text.Icon);
|
||||
Assert.NotNull(capturedPayload.Discord.Images.Thumbnail);
|
||||
Assert.Contains("Cleanuparr", capturedPayload.Discord.Images.Thumbnail.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesContextImage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Image = new Uri("https://example.com/image.jpg");
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(new Uri("https://example.com/image.jpg"), capturedPayload.Discord.Images.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenNoImage_ImagesImageIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Image = null;
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Null(capturedPayload.Discord.Images.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.ThrowsAsync(new Exception("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_HasEmptyFields()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Empty(capturedPayload.Discord.Text.Fields);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationConfigurationServiceTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly Mock<ILogger<NotificationConfigurationService>> _loggerMock;
|
||||
private readonly NotificationConfigurationService _service;
|
||||
|
||||
public NotificationConfigurationServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new DataContext(options);
|
||||
_loggerMock = new Mock<ILogger<NotificationConfigurationService>>();
|
||||
_service = new NotificationConfigurationService(_context, _loggerMock.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.EnsureDeleted();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
#region GetActiveProvidersAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_NoProviders_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_WithEnabledProvider_ReturnsProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test Provider", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Test Provider", result[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_WithDisabledProvider_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Disabled Provider", isEnabled: false);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_CachesResults()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test Provider", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act - Call twice
|
||||
var result1 = await _service.GetActiveProvidersAsync();
|
||||
var result2 = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert - Both calls should return same data
|
||||
Assert.Single(result1);
|
||||
Assert.Single(result2);
|
||||
Assert.Equal(result1[0].Id, result2[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_WithMixedProviders_ReturnsOnlyEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var enabledConfig = CreateNotifiarrConfig("Enabled", isEnabled: true);
|
||||
var disabledConfig = CreateNotifiarrConfig("Disabled", isEnabled: false);
|
||||
_context.Set<NotificationConfig>().AddRange(enabledConfig, disabledConfig);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Enabled", result[0].Name);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetProvidersForEventAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetProvidersForEventAsync_NoMatchingProviders_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: false);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProvidersForEventAsync_WithMatchingProvider_ReturnsProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProvidersForEventAsync_TestEvent_AlwaysReturnsEnabledProviders()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: false);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(NotificationEventType.Test);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationEventType.FailedImportStrike, true, false, false, false, false, false)]
|
||||
[InlineData(NotificationEventType.StalledStrike, false, true, false, false, false, false)]
|
||||
[InlineData(NotificationEventType.SlowSpeedStrike, false, false, true, false, false, false)]
|
||||
[InlineData(NotificationEventType.SlowTimeStrike, false, false, true, false, false, false)]
|
||||
[InlineData(NotificationEventType.QueueItemDeleted, false, false, false, true, false, false)]
|
||||
[InlineData(NotificationEventType.DownloadCleaned, false, false, false, false, true, false)]
|
||||
[InlineData(NotificationEventType.CategoryChanged, false, false, false, false, false, true)]
|
||||
public async Task GetProvidersForEventAsync_ReturnsProviderForCorrectEvents(
|
||||
NotificationEventType eventType,
|
||||
bool onFailedImport, bool onStalled, bool onSlow,
|
||||
bool onDeleted, bool onCleaned, bool onCategory)
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = true,
|
||||
OnFailedImportStrike = onFailedImport,
|
||||
OnStalledStrike = onStalled,
|
||||
OnSlowStrike = onSlow,
|
||||
OnQueueItemDeleted = onDeleted,
|
||||
OnDownloadCleaned = onCleaned,
|
||||
OnCategoryChanged = onCategory,
|
||||
NotifiarrConfiguration = new NotifiarrConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiKey = "testapikey1234567890",
|
||||
ChannelId = "123456789012345678"
|
||||
}
|
||||
};
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(eventType);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetProviderByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetProviderByIdAsync_ProviderExists_ReturnsProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProviderByIdAsync(config.Id);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(config.Id, result.Id);
|
||||
Assert.Equal("Test", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProviderByIdAsync_ProviderDoesNotExist_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetProviderByIdAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProviderByIdAsync_DisabledProvider_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Disabled", isEnabled: false);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProviderByIdAsync(config.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvalidateCacheAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateCacheAsync_RefreshesDataOnNextCall()
|
||||
{
|
||||
// Arrange
|
||||
var config1 = CreateNotifiarrConfig("Provider 1", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config1);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// First call to populate cache
|
||||
var result1 = await _service.GetActiveProvidersAsync();
|
||||
Assert.Single(result1);
|
||||
|
||||
// Add another provider
|
||||
var config2 = CreateNotifiarrConfig("Provider 2", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config2);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Without invalidation, should return cached result
|
||||
var result2 = await _service.GetActiveProvidersAsync();
|
||||
Assert.Single(result2);
|
||||
|
||||
// After invalidation, should return updated result
|
||||
await _service.InvalidateCacheAsync();
|
||||
var result3 = await _service.GetActiveProvidersAsync();
|
||||
Assert.Equal(2, result3.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateCacheAsync_LogsDebugMessage()
|
||||
{
|
||||
// Act
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("cache invalidated")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetProvidersForEventAsync_UnknownEventType_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
var unknownEventType = (NotificationEventType)999;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
||||
() => _service.GetProvidersForEventAsync(unknownEventType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_DatabaseError_ReturnsEmptyListAndLogsError()
|
||||
{
|
||||
// Arrange - dispose context to simulate database error
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var disposedContext = new DataContext(options);
|
||||
var loggerMock = new Mock<ILogger<NotificationConfigurationService>>();
|
||||
var service = new NotificationConfigurationService(disposedContext, loggerMock.Object);
|
||||
|
||||
await disposedContext.DisposeAsync();
|
||||
|
||||
// Act
|
||||
var result = await service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to load notification providers")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Provider Type Mapping Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateConfigForType(providerType, "Test Provider", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(providerType, result[0].Type);
|
||||
Assert.Equal("Test Provider", result[0].Name);
|
||||
Assert.NotNull(result[0].Configuration);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateConfigForType(providerType, "Test", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(providerType, result[0].Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationConfig CreateConfigForType(
|
||||
NotificationProviderType providerType,
|
||||
string name,
|
||||
bool isEnabled)
|
||||
{
|
||||
return providerType switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => CreateNotifiarrConfig(name, isEnabled),
|
||||
NotificationProviderType.Apprise => CreateAppriseConfig(name, isEnabled),
|
||||
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
|
||||
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateNotifiarrConfig(
|
||||
string name,
|
||||
bool isEnabled,
|
||||
bool onStalledStrike = true,
|
||||
bool onFailedImport = true,
|
||||
bool onSlow = true,
|
||||
bool onDeleted = true,
|
||||
bool onCleaned = true,
|
||||
bool onCategory = true)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = onStalledStrike,
|
||||
OnFailedImportStrike = onFailedImport,
|
||||
OnSlowStrike = onSlow,
|
||||
OnQueueItemDeleted = onDeleted,
|
||||
OnDownloadCleaned = onCleaned,
|
||||
OnCategoryChanged = onCategory,
|
||||
NotifiarrConfiguration = new NotifiarrConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiKey = "testapikey1234567890",
|
||||
ChannelId = "123456789012345678"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateAppriseConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
AppriseConfiguration = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://localhost:8000",
|
||||
Key = "testkey"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateNtfyConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
NtfyConfiguration = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "https://ntfy.sh",
|
||||
Topics = ["test-topic"]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreatePushoverConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
PushoverConfiguration = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "test_api_token_1234567890abcd",
|
||||
UserKey = "test_user_key_1234567890abcde"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationConsumerTests
|
||||
{
|
||||
private readonly Mock<ILogger<NotificationService>> _serviceLoggerMock;
|
||||
private readonly Mock<INotificationConfigurationService> _configurationServiceMock;
|
||||
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
|
||||
private readonly NotificationService _notificationService;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public NotificationConsumerTests()
|
||||
{
|
||||
_serviceLoggerMock = new Mock<ILogger<NotificationService>>();
|
||||
_configurationServiceMock = new Mock<INotificationConfigurationService>();
|
||||
_providerFactoryMock = new Mock<INotificationProviderFactory>();
|
||||
_timeProvider = new FakeTimeProvider();
|
||||
|
||||
_notificationService = new NotificationService(
|
||||
_serviceLoggerMock.Object,
|
||||
_configurationServiceMock.Object,
|
||||
_providerFactoryMock.Object);
|
||||
}
|
||||
|
||||
#region Consume Tests - FailedImportStrikeNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_FailedImportStrikeNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test Failed Import",
|
||||
Description = "Test Description",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "TEST123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.FailedImportStrike, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - StalledStrikeNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_StalledStrikeNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<StalledStrikeNotification>();
|
||||
var notification = new StalledStrikeNotification
|
||||
{
|
||||
Title = "Test Stalled",
|
||||
Description = "Stalled Description",
|
||||
Level = NotificationLevel.Important,
|
||||
InstanceType = InstanceType.Sonarr,
|
||||
InstanceUrl = new Uri("http://sonarr.local"),
|
||||
Hash = "STALL123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.StalledStrike, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - SlowSpeedStrikeNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_SlowSpeedStrikeNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<SlowSpeedStrikeNotification>();
|
||||
var notification = new SlowSpeedStrikeNotification
|
||||
{
|
||||
Title = "Slow Speed",
|
||||
Description = "Download too slow",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "SLOW123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.SlowSpeedStrike, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - SlowTimeStrikeNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_SlowTimeStrikeNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<SlowTimeStrikeNotification>();
|
||||
var notification = new SlowTimeStrikeNotification
|
||||
{
|
||||
Title = "Slow Time",
|
||||
Description = "Download taking too long",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "TIME123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.SlowTimeStrike, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - QueueItemDeletedNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_QueueItemDeletedNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<QueueItemDeletedNotification>();
|
||||
var notification = new QueueItemDeletedNotification
|
||||
{
|
||||
Title = "Item Deleted",
|
||||
Description = "Queue item removed",
|
||||
Level = NotificationLevel.Important,
|
||||
InstanceType = InstanceType.Lidarr,
|
||||
InstanceUrl = new Uri("http://lidarr.local"),
|
||||
Hash = "DEL123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.QueueItemDeleted, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - DownloadCleanedNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_DownloadCleanedNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<DownloadCleanedNotification>();
|
||||
var notification = new DownloadCleanedNotification
|
||||
{
|
||||
Title = "Download Cleaned",
|
||||
Description = "Old download removed",
|
||||
Level = NotificationLevel.Information
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.DownloadCleaned, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - CategoryChangedNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_CategoryChangedNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<CategoryChangedNotification>();
|
||||
var notification = new CategoryChangedNotification
|
||||
{
|
||||
Title = "Category Changed",
|
||||
Description = "Category updated",
|
||||
Level = NotificationLevel.Information
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.CategoryChanged, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotificationContext Conversion Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationLevel.Information, EventSeverity.Information)]
|
||||
[InlineData(NotificationLevel.Warning, EventSeverity.Warning)]
|
||||
[InlineData(NotificationLevel.Important, EventSeverity.Important)]
|
||||
public async Task Consume_MapsNotificationLevelToSeverity(NotificationLevel level, EventSeverity expectedSeverity)
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test",
|
||||
Description = "Test",
|
||||
Level = level,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "LEVEL123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal(expectedSeverity, capturedContext.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_ArrNotification_IncludesArrDataInContext()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test",
|
||||
Description = "Test",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Sonarr,
|
||||
InstanceUrl = new Uri("http://sonarr.local"),
|
||||
Hash = "ABC123",
|
||||
Image = new Uri("http://example.com/image.jpg")
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("Sonarr", capturedContext.Data["Instance type"]);
|
||||
Assert.Equal("http://sonarr.local/", capturedContext.Data["Url"]);
|
||||
Assert.Equal("ABC123", capturedContext.Data["Hash"]);
|
||||
Assert.Equal(new Uri("http://example.com/image.jpg"), capturedContext.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithCustomFields_IncludesFieldsInContext()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test",
|
||||
Description = "Test",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "XYZ789",
|
||||
Fields = new List<NotificationField>
|
||||
{
|
||||
new() { Key = "CustomKey1", Value = "CustomValue1" },
|
||||
new() { Key = "CustomKey2", Value = "CustomValue2" }
|
||||
}
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("CustomValue1", capturedContext.Data["CustomKey1"]);
|
||||
Assert.Equal("CustomValue2", capturedContext.Data["CustomKey2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_NonArrNotification_DoesNotIncludeArrData()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<DownloadCleanedNotification>();
|
||||
var notification = new DownloadCleanedNotification
|
||||
{
|
||||
Title = "Download Cleaned",
|
||||
Description = "Test",
|
||||
Level = NotificationLevel.Information
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.False(capturedContext.Data.ContainsKey("Instance type"));
|
||||
Assert.False(capturedContext.Data.ContainsKey("Url"));
|
||||
Assert.False(capturedContext.Data.ContainsKey("Hash"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region No Providers Configured Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WhenNoProvidersConfigured_DoesNotSendNotification()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test",
|
||||
Description = "Test",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "NOPROV123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto>());
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
_providerFactoryMock.Verify(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()), Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private NotificationConsumer<T> CreateConsumer<T>() where T : Notification
|
||||
{
|
||||
var loggerMock = new Mock<ILogger<NotificationConsumer<T>>>();
|
||||
return new NotificationConsumer<T>(loggerMock.Object, _notificationService, _timeProvider);
|
||||
}
|
||||
|
||||
private static Mock<ConsumeContext<T>> CreateConsumeContextMock<T>(T message) where T : class
|
||||
{
|
||||
var mock = new Mock<ConsumeContext<T>>();
|
||||
mock.Setup(c => c.Message).Returns(message);
|
||||
return mock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the consumer and advances time past the 1-second spam prevention delay
|
||||
/// </summary>
|
||||
private async Task ConsumeWithTimeAdvance<T>(NotificationConsumer<T> consumer, Mock<ConsumeContext<T>> contextMock) where T : Notification
|
||||
{
|
||||
var task = consumer.Consume(contextMock.Object);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await task;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationProviderFactoryTests
|
||||
{
|
||||
private readonly Mock<IAppriseProxy> _appriseProxyMock;
|
||||
private readonly Mock<INtfyProxy> _ntfyProxyMock;
|
||||
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
|
||||
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NotificationProviderFactory _factory;
|
||||
|
||||
public NotificationProviderFactoryTests()
|
||||
{
|
||||
_appriseProxyMock = new Mock<IAppriseProxy>();
|
||||
_ntfyProxyMock = new Mock<INtfyProxy>();
|
||||
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
|
||||
_pushoverProxyMock = new Mock<IPushoverProxy>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_appriseProxyMock.Object);
|
||||
services.AddSingleton(_ntfyProxyMock.Object);
|
||||
services.AddSingleton(_notifiarrProxyMock.Object);
|
||||
services.AddSingleton(_pushoverProxyMock.Object);
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_factory = new NotificationProviderFactory(_serviceProvider);
|
||||
}
|
||||
|
||||
#region CreateProvider Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_AppriseType_CreatesAppriseProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestApprise",
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true,
|
||||
Configuration = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://apprise.example.com",
|
||||
Key = "testkey"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<AppriseProvider>(provider);
|
||||
Assert.Equal("TestApprise", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Apprise, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_NtfyType_CreatesNtfyProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestNtfy",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true,
|
||||
Configuration = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "test-topic" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<NtfyProvider>(provider);
|
||||
Assert.Equal("TestNtfy", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Ntfy, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_NotifiarrType_CreatesNotifiarrProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestNotifiarr",
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = true,
|
||||
Configuration = new NotifiarrConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiKey = "testapikey1234567890",
|
||||
ChannelId = "123456789012345678"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<NotifiarrProvider>(provider);
|
||||
Assert.Equal("TestNotifiarr", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Notifiarr, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_PushoverType_CreatesPushoverProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestPushover",
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = true,
|
||||
Configuration = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "test-api-token",
|
||||
UserKey = "test-user-key",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<PushoverProvider>(provider);
|
||||
Assert.Equal("TestPushover", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Pushover, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestUnsupported",
|
||||
Type = (NotificationProviderType)999, // Invalid type
|
||||
IsEnabled = true,
|
||||
Configuration = new object()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotSupportedException>(() => _factory.CreateProvider(config));
|
||||
Assert.Contains("not supported", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_AppriseType_UsesCorrectProxy()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestApprise",
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true,
|
||||
Configuration = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://apprise.example.com",
|
||||
Key = "testkey"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert - provider was created with the injected proxy
|
||||
Assert.NotNull(provider);
|
||||
// The proxy would be used when SendNotificationAsync is called
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_PreservesProviderName()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "My Custom Provider Name",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true,
|
||||
Configuration = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "test" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("My Custom Provider Name", provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_PreservesProviderType()
|
||||
{
|
||||
// Arrange
|
||||
var configs = new[]
|
||||
{
|
||||
(Type: NotificationProviderType.Apprise, Config: (object)new AppriseConfig { Id = Guid.NewGuid(), Url = "http://test.com", Key = "key" }),
|
||||
(Type: NotificationProviderType.Ntfy, Config: (object)new NtfyConfig { Id = Guid.NewGuid(), ServerUrl = "http://test.com", Topics = new List<string> { "t" }, AuthenticationType = NtfyAuthenticationType.None, Priority = NtfyPriority.Default }),
|
||||
(Type: NotificationProviderType.Notifiarr, Config: (object)new NotifiarrConfig { Id = Guid.NewGuid(), ApiKey = "1234567890", ChannelId = "12345" }),
|
||||
(Type: NotificationProviderType.Pushover, Config: (object)new PushoverConfig { Id = Guid.NewGuid(), ApiToken = "token", UserKey = "user", Devices = new List<string>(), Priority = PushoverPriority.Normal, Sound = "", Tags = new List<string>() })
|
||||
};
|
||||
|
||||
foreach (var (type, configObj) in configs)
|
||||
{
|
||||
var dto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Test-{type}",
|
||||
Type = type,
|
||||
IsEnabled = true,
|
||||
Configuration = configObj
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(dto);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(type, provider.Type);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Service Resolution Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_WhenProxyNotRegistered_ThrowsException()
|
||||
{
|
||||
// Arrange - create a service provider without the proxy
|
||||
var emptyServices = new ServiceCollection();
|
||||
var emptyServiceProvider = emptyServices.BuildServiceProvider();
|
||||
var factoryWithNoServices = new NotificationProviderFactory(emptyServiceProvider);
|
||||
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestApprise",
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true,
|
||||
Configuration = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://test.com",
|
||||
Key = "key"
|
||||
}
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => factoryWithNoServices.CreateProvider(config));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationPublisherTests
|
||||
{
|
||||
private readonly Mock<ILogger<NotificationPublisher>> _loggerMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly Mock<INotificationConfigurationService> _configServiceMock;
|
||||
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
|
||||
private readonly NotificationPublisher _publisher;
|
||||
|
||||
public NotificationPublisherTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<NotificationPublisher>>();
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
_configServiceMock = new Mock<INotificationConfigurationService>();
|
||||
_providerFactoryMock = new Mock<INotificationProviderFactory>();
|
||||
|
||||
// Setup dry run interceptor to call through
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns<Delegate, object[]>(async (action, parameters) =>
|
||||
{
|
||||
if (action is Func<(NotificationEventType, NotificationContext), Task> func && parameters.Length > 0)
|
||||
{
|
||||
var param = ((NotificationEventType, NotificationContext))parameters[0];
|
||||
await func(param);
|
||||
}
|
||||
});
|
||||
|
||||
_publisher = new NotificationPublisher(
|
||||
_loggerMock.Object,
|
||||
_dryRunInterceptorMock.Object,
|
||||
_configServiceMock.Object,
|
||||
_providerFactoryMock.Object);
|
||||
}
|
||||
|
||||
private void SetupContext(InstanceType instanceType = InstanceType.Sonarr)
|
||||
{
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Show",
|
||||
DownloadId = "ABCD1234",
|
||||
Status = "Downloading",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
ContextProvider.Set(nameof(QueueRecord), record);
|
||||
ContextProvider.Set(nameof(InstanceType), (object)instanceType);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://sonarr.local"));
|
||||
}
|
||||
|
||||
private void SetupDownloadCleanerContext()
|
||||
{
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "HASH123");
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsAllDependencies()
|
||||
{
|
||||
// Assert
|
||||
Assert.NotNull(_publisher);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyStrike Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WithStalledStrike_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
var rule = new StallRule { Name = "Test Rule" };
|
||||
ContextProvider.Set<QueueRule>(rule);
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.StalledStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.Stalled, 1);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.StalledStrike &&
|
||||
c.Data.ContainsKey("Strike type") &&
|
||||
c.Data["Strike type"] == "Stalled")), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WithFailedImportStrike_MapsToCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 2);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.FailedImportStrike &&
|
||||
c.Data["Strike count"] == "2")), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StrikeType.Stalled, NotificationEventType.StalledStrike)]
|
||||
[InlineData(StrikeType.DownloadingMetadata, NotificationEventType.StalledStrike)]
|
||||
[InlineData(StrikeType.FailedImport, NotificationEventType.FailedImportStrike)]
|
||||
[InlineData(StrikeType.SlowSpeed, NotificationEventType.SlowSpeedStrike)]
|
||||
[InlineData(StrikeType.SlowTime, NotificationEventType.SlowTimeStrike)]
|
||||
public async Task NotifyStrike_MapsStrikeTypeToCorrectEventType(StrikeType strikeType, NotificationEventType expectedEventType)
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
if (strikeType is StrikeType.Stalled or StrikeType.SlowSpeed or StrikeType.SlowTime)
|
||||
{
|
||||
var rule = new StallRule { Name = "Test Rule" };
|
||||
ContextProvider.Set<QueueRule>(rule);
|
||||
}
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(expectedEventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(strikeType, 1);
|
||||
|
||||
// Assert
|
||||
_configServiceMock.Verify(c => c.GetProvidersForEventAsync(expectedEventType), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WhenNoProviders_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto>());
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WhenProviderThrows_LogsWarningAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Provider failed"));
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act - Should not throw
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyQueueItemDeleted Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyQueueItemDeleted_SendsNotificationWithCorrectContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.QueueItemDeleted &&
|
||||
c.Data["Reason"] == "Stalled" &&
|
||||
c.Data["Removed from client?"] == "True" &&
|
||||
c.Severity == EventSeverity.Important)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyQueueItemDeleted_WhenRemoveFromClientFalse_ReflectsInContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.MalwareFileFound);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.Data["Removed from client?"] == "False" &&
|
||||
c.Data["Reason"] == "MalwareFileFound")), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyDownloadCleaned Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDownloadCleaned_SendsNotificationWithCorrectContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyDownloadCleaned(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.DownloadCleaned &&
|
||||
c.Description == "Test Download" &&
|
||||
c.Data["Category"] == "movies" &&
|
||||
c.Data["Ratio"] == "2.5" &&
|
||||
c.Data["Seeding hours"] == "48")), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDownloadCleaned_WithSeedingTime_RoundsToWholeHours()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(24.7), "tv", CleanReason.MaxSeedTimeReached);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("25", capturedContext.Data["Seeding hours"]); // Rounds to 25
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyCategoryChanged Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyCategoryChanged_WhenNotTag_IncludesOldAndNewCategory()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyCategoryChanged("tv-sonarr", "seeding", false);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.CategoryChanged &&
|
||||
c.Title == "Category changed" &&
|
||||
c.Data["Old category"] == "tv-sonarr" &&
|
||||
c.Data["New category"] == "seeding")), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyCategoryChanged_WhenIsTag_IncludesOnlyTag()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyCategoryChanged("", "seeded", true);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("Tag added", capturedContext.Title);
|
||||
Assert.True(capturedContext.Data.ContainsKey("Tag"));
|
||||
Assert.Equal("seeded", capturedContext.Data["Tag"]);
|
||||
Assert.False(capturedContext.Data.ContainsKey("Old category"));
|
||||
Assert.False(capturedContext.Data.ContainsKey("New category"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyCategoryChanged_SetsSeverityToInformation()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyCategoryChanged("old", "new", false);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.Severity == EventSeverity.Information)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests (through notify methods)
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenMultipleProviders_SendsToAll()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto1 = CreateProviderDto("Provider1");
|
||||
var providerDto2 = CreateProviderDto("Provider2");
|
||||
var providerMock1 = new Mock<INotificationProvider>();
|
||||
var providerMock2 = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto1, providerDto2 });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto1))
|
||||
.Returns(providerMock1.Object);
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto2))
|
||||
.Returns(providerMock2.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
providerMock1.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
|
||||
providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenOneProviderFails_OthersStillSend()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto1 = CreateProviderDto("Provider1");
|
||||
var providerDto2 = CreateProviderDto("Provider2");
|
||||
var providerMock1 = new Mock<INotificationProvider>();
|
||||
var providerMock2 = new Mock<INotificationProvider>();
|
||||
|
||||
providerMock1.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Failed"));
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto1, providerDto2 });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto1))
|
||||
.Returns(providerMock1.Object);
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto2))
|
||||
.Returns(providerMock2.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert - Provider2 should still be called
|
||||
providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_UsesDryRunInterceptor()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto>());
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
|
||||
It.IsAny<Func<(NotificationEventType, NotificationContext), Task>>(),
|
||||
It.IsAny<(NotificationEventType, NotificationContext)>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WhenExceptionOccurs_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
// Setup dry run interceptor to throw when called
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.ThrowsAsync(new Exception("Interceptor failed"));
|
||||
|
||||
SetupContext();
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to notify strike")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyQueueItemDeleted_WhenExceptionOccurs_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.ThrowsAsync(new Exception("Error"));
|
||||
|
||||
SetupContext();
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify queue item deleted")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDownloadCleaned_WhenExceptionOccurs_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.ThrowsAsync(new Exception("Error"));
|
||||
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(1), "test", CleanReason.MaxRatioReached);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify download cleaned")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyCategoryChanged_WhenExceptionOccurs_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.ThrowsAsync(new Exception("Error"));
|
||||
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyCategoryChanged("old", "new", false);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify category changed")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationProviderDto CreateProviderDto(string name = "TestProvider")
|
||||
{
|
||||
return new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true
|
||||
},
|
||||
Configuration = new { ApiKey = "test", ChannelId = "123" }
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<NotificationService>> _loggerMock;
|
||||
private readonly Mock<INotificationConfigurationService> _configServiceMock;
|
||||
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
|
||||
private readonly NotificationService _service;
|
||||
|
||||
public NotificationServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<NotificationService>>();
|
||||
_configServiceMock = new Mock<INotificationConfigurationService>();
|
||||
_providerFactoryMock = new Mock<INotificationProviderFactory>();
|
||||
|
||||
_service = new NotificationService(
|
||||
_loggerMock.Object,
|
||||
_configServiceMock.Object,
|
||||
_providerFactoryMock.Object);
|
||||
}
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_NoProviders_DoesNotSendNotifications()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.QueueItemDeleted;
|
||||
var context = CreateTestContext();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto>());
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
_providerFactoryMock.Verify(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithProvider_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.DownloadCleaned;
|
||||
var context = CreateTestContext();
|
||||
var providerConfig = CreateProviderConfig("TestProvider");
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerConfig });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithMultipleProviders_SendsToAll()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.StalledStrike;
|
||||
var context = CreateTestContext();
|
||||
var provider1Config = CreateProviderConfig("Provider1");
|
||||
var provider2Config = CreateProviderConfig("Provider2");
|
||||
|
||||
var provider1Mock = new Mock<INotificationProvider>();
|
||||
provider1Mock.SetupGet(p => p.Name).Returns("Provider1");
|
||||
|
||||
var provider2Mock = new Mock<INotificationProvider>();
|
||||
provider2Mock.SetupGet(p => p.Name).Returns("Provider2");
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { provider1Config, provider2Config });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(provider1Config))
|
||||
.Returns(provider1Mock.Object);
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(provider2Config))
|
||||
.Returns(provider2Mock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
provider1Mock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
provider2Mock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_OneProviderFails_OthersStillExecute()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.CategoryChanged;
|
||||
var context = CreateTestContext();
|
||||
var failingProviderConfig = CreateProviderConfig("FailingProvider");
|
||||
var successProviderConfig = CreateProviderConfig("SuccessProvider");
|
||||
|
||||
var failingProviderMock = new Mock<INotificationProvider>();
|
||||
failingProviderMock.SetupGet(p => p.Name).Returns("FailingProvider");
|
||||
failingProviderMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Provider failed"));
|
||||
|
||||
var successProviderMock = new Mock<INotificationProvider>();
|
||||
successProviderMock.SetupGet(p => p.Name).Returns("SuccessProvider");
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { failingProviderConfig, successProviderConfig });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(failingProviderConfig))
|
||||
.Returns(failingProviderMock.Object);
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(successProviderConfig))
|
||||
.Returns(successProviderMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert - both providers should have been called
|
||||
failingProviderMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
successProviderMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_ProviderFails_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.QueueItemDeleted;
|
||||
var context = CreateTestContext();
|
||||
var providerConfig = CreateProviderConfig("FailingProvider");
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Provider failed"));
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerConfig });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_ConfigServiceThrows_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.SlowSpeedStrike;
|
||||
var context = CreateTestContext();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ThrowsAsync(new Exception("Config service failed"));
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notifications")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendTestNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_SendsTestContext()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = CreateProviderConfig("TestProvider");
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendTestNotificationAsync(providerConfig);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(c =>
|
||||
c.EventType == NotificationEventType.Test &&
|
||||
c.Title == "Test Notification from Cleanuparr" &&
|
||||
c.Description.Contains("test notification") &&
|
||||
c.Severity == EventSeverity.Information &&
|
||||
c.Data != null &&
|
||||
c.Data.ContainsKey("Test time") &&
|
||||
c.Data.ContainsKey("Provider type")
|
||||
)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_Success_LogsInformation()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = CreateProviderConfig("TestProvider");
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendTestNotificationAsync(providerConfig);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Test notification sent successfully")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_ProviderFails_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = CreateProviderConfig("FailingProvider");
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Test notification failed"));
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _service.SendTestNotificationAsync(providerConfig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_ProviderFails_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = CreateProviderConfig("FailingProvider");
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Test notification failed"));
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await _service.SendTestNotificationAsync(providerConfig);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send test notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_IncludesProviderTypeInData()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestNtfyProvider",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("TestNtfyProvider");
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendTestNotificationAsync(providerConfig);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(c =>
|
||||
c.Data["Provider type"] == "Ntfy"
|
||||
)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["Key1"] = "Value1",
|
||||
["Key2"] = "Value2"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationProviderDto CreateProviderConfig(string name)
|
||||
{
|
||||
return new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Ntfy;
|
||||
|
||||
public class NtfyProxyTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public NtfyProxyTests()
|
||||
{
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private NtfyProxy CreateProxy()
|
||||
{
|
||||
return new NtfyProxy(_httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static NtfyPayload CreatePayload()
|
||||
{
|
||||
return new NtfyPayload
|
||||
{
|
||||
Topic = "test-topic",
|
||||
Message = "Test message",
|
||||
Title = "Test Title"
|
||||
};
|
||||
}
|
||||
|
||||
private static NtfyConfig CreateConfig(NtfyAuthenticationType authType = NtfyAuthenticationType.None)
|
||||
{
|
||||
return new NtfyConfig
|
||||
{
|
||||
ServerUrl = "http://ntfy.local",
|
||||
Topics = new List<string> { "test-topic" },
|
||||
AuthenticationType = authType,
|
||||
Username = authType == NtfyAuthenticationType.BasicAuth ? "user" : null,
|
||||
Password = authType == NtfyAuthenticationType.BasicAuth ? "pass" : null,
|
||||
AccessToken = authType == NtfyAuthenticationType.AccessToken ? "token123" : null
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidFactory_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SetsJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Authentication Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithNoAuth_DoesNotSetAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
bool hasAuthHeader = false;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
hasAuthHeader = req.Headers.Authorization != null)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.None));
|
||||
|
||||
// Assert
|
||||
Assert.False(hasAuthHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithBasicAuth_SetsBasicAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedAuthScheme = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedAuthScheme = req.Headers.Authorization?.Scheme)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.BasicAuth));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Basic", capturedAuthScheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithAccessToken_SetsBearerAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedAuthScheme = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedAuthScheme = req.Headers.Authorization?.Scheme)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.AccessToken));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Bearer", capturedAuthScheme);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When400_ThrowsNtfyExceptionWithBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.BadRequest);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Bad request", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsNtfyExceptionWithUnauthorized()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unauthorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When413_ThrowsNtfyExceptionWithPayloadTooLarge()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.RequestEntityTooLarge);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Payload too large", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When429_ThrowsNtfyExceptionWithRateLimited()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.TooManyRequests);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Rate limited", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When507_ThrowsNtfyExceptionWithInsufficientStorage()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InsufficientStorage);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Insufficient storage", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsNtfyException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unable to send notification", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsNtfyException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unable to send notification", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NtfyProviderTests
|
||||
{
|
||||
private readonly Mock<INtfyProxy> _proxyMock;
|
||||
private readonly NtfyConfig _config;
|
||||
private readonly NtfyProvider _provider;
|
||||
|
||||
public NtfyProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<INtfyProxy>();
|
||||
_config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "test-topic" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default,
|
||||
Tags = new List<string> { "tag1", "tag2" }
|
||||
};
|
||||
|
||||
_provider = new NtfyProvider(
|
||||
"TestNtfy",
|
||||
NotificationProviderType.Ntfy,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestNtfy", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Ntfy, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NtfyPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("test-topic", capturedPayload.Topic);
|
||||
Assert.Equal(context.Title, capturedPayload.Title);
|
||||
Assert.Contains(context.Description, capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithMultipleTopics_SendsToAllTopics()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "topic1", "topic2", "topic3" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
var capturedPayloads = new List<NtfyPayload>();
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayloads.Add(payload))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, capturedPayloads.Count);
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "topic1");
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "topic2");
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "topic3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
NtfyPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("TestKey: TestValue", capturedPayload.Message);
|
||||
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_UsesPriorityFromConfig()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "test" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.High,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
NtfyPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal((int)NtfyPriority.High, capturedPayload.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesTagsFromConfig()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NtfyPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.NotNull(capturedPayload.Tags);
|
||||
Assert.Contains("tag1", capturedPayload.Tags);
|
||||
Assert.Contains("tag2", capturedPayload.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TrimsTopicNames()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { " topic-with-spaces " },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
NtfyPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("topic-with-spaces", capturedPayload.Topic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_SkipsEmptyTopics()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "valid-topic", "", " ", "another-valid" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
var capturedPayloads = new List<NtfyPayload>();
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayloads.Add(payload))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, capturedPayloads.Count);
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "valid-topic");
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "another-valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.ThrowsAsync(new Exception("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_MessageContainsOnlyDescription()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description Only",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
NtfyPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("Test Description Only", capturedPayload.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Pushover;
|
||||
|
||||
public class PushoverProxyTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public PushoverProxyTests()
|
||||
{
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private PushoverProxy CreateProxy()
|
||||
{
|
||||
return new PushoverProxy(_httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static PushoverPayload CreatePayload(int priority = 0)
|
||||
{
|
||||
return new PushoverPayload
|
||||
{
|
||||
Token = "test-token",
|
||||
User = "test-user",
|
||||
Message = "Test message",
|
||||
Title = "Test Title",
|
||||
Priority = priority,
|
||||
Retry = priority == 2 ? 60 : null,
|
||||
Expire = priority == 2 ? 3600 : null
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidFactory_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsToCorrectUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Equal("https://api.pushover.net/1/messages.json", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_UsesFormUrlEncodedContent()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/x-www-form-urlencoded", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_IncludesRequiredFieldsInPayload()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = CreatePayload();
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("token=test-token", capturedContent);
|
||||
Assert.Contains("user=test-user", capturedContent);
|
||||
Assert.Contains("message=Test+message", capturedContent);
|
||||
Assert.Contains("priority=0", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithEmergencyPriority_IncludesRetryAndExpire()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = CreatePayload(priority: 2); // Emergency
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("retry=60", capturedContent);
|
||||
Assert.Contains("expire=3600", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithNonEmergencyPriority_DoesNotIncludeRetryAndExpire()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = CreatePayload(priority: 1); // High, not Emergency
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.DoesNotContain("retry=", capturedContent);
|
||||
Assert.DoesNotContain("expire=", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithSound_IncludesSound()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = new PushoverPayload
|
||||
{
|
||||
Token = "test-token",
|
||||
User = "test-user",
|
||||
Message = "Test message",
|
||||
Title = "Test Title",
|
||||
Priority = 0,
|
||||
Sound = "cosmic"
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("sound=cosmic", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithDevice_IncludesDevice()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = new PushoverPayload
|
||||
{
|
||||
Token = "test-token",
|
||||
User = "test-user",
|
||||
Message = "Test message",
|
||||
Title = "Test Title",
|
||||
Priority = 0,
|
||||
Device = "my-phone"
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("device=my-phone", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithTags_IncludesTags()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = new PushoverPayload
|
||||
{
|
||||
Token = "test-token",
|
||||
User = "test-user",
|
||||
Message = "Test message",
|
||||
Title = "Test Title",
|
||||
Priority = 0,
|
||||
Tags = "tag1,tag2"
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("tags=tag1%2Ctag2", capturedContent); // URL-encoded comma
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When400_ThrowsPushoverExceptionWithBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.BadRequest, "{\"status\":0,\"errors\":[\"invalid token\"]}");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("Bad request", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsPushoverExceptionWithUnauthorized()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized, "{\"status\":0,\"errors\":[\"invalid api key\"]}");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("Invalid API token or user key", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When429_ThrowsPushoverExceptionWithRateLimited()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse((HttpStatusCode)429, "{\"status\":0,\"errors\":[\"rate limit exceeded\"]}");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("Rate limit exceeded", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenApiReturnsStatus0_ThrowsPushoverException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"status\":0,\"errors\":[\"user key is invalid\"]}")
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("user key is invalid", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsPushoverException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("Unable to connect to Pushover API", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateSuccessResponse()
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"status\":1,\"request\":\"abc123\"}")
|
||||
};
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode, string responseBody)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(responseBody)
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class PushoverProviderTests
|
||||
{
|
||||
private readonly Mock<IPushoverProxy> _proxyMock;
|
||||
private readonly PushoverConfig _config;
|
||||
private readonly PushoverProvider _provider;
|
||||
|
||||
public PushoverProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<IPushoverProxy>();
|
||||
_config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "test-api-token",
|
||||
UserKey = "test-user-key",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Retry = null,
|
||||
Expire = null,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
_provider = new PushoverProvider(
|
||||
"TestPushover",
|
||||
NotificationProviderType.Pushover,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestPushover", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Pushover, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
PushoverPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("test-api-token", capturedPayload.Token);
|
||||
Assert.Equal("test-user-key", capturedPayload.User);
|
||||
Assert.Equal(context.Title, capturedPayload.Title);
|
||||
Assert.Contains(context.Description, capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("TestKey: TestValue", capturedPayload.Message);
|
||||
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_UsesPriorityFromConfig()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.High,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal((int)PushoverPriority.High, capturedPayload.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmergencyPriority_IncludesRetryAndExpire()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Emergency,
|
||||
Sound = "",
|
||||
Retry = 60,
|
||||
Expire = 3600,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal((int)PushoverPriority.Emergency, capturedPayload.Priority);
|
||||
Assert.Equal(60, capturedPayload.Retry);
|
||||
Assert.Equal(3600, capturedPayload.Expire);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithNonEmergencyPriority_DoesNotIncludeRetryAndExpire()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.High, // Not Emergency
|
||||
Sound = "",
|
||||
Retry = 60, // Should be ignored
|
||||
Expire = 3600, // Should be ignored
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Null(capturedPayload.Retry);
|
||||
Assert.Null(capturedPayload.Expire);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithDevices_JoinsDevicesAsString()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string> { "device1", "device2", "device3" },
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("device1,device2,device3", capturedPayload.Device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyDevices_DeviceIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Null(capturedPayload.Device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithTags_JoinsTagsAsString()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string> { "tag1", "tag2" }
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("tag1,tag2", capturedPayload.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithSound_IncludesSound()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "cosmic",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("cosmic", capturedPayload.Sound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TruncatesLongMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = new string('A', 2000), // Very long message
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.True(capturedPayload.Message.Length <= 1024);
|
||||
Assert.EndsWith("...", capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TruncatesLongTitle()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = new string('B', 300), // Very long title
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.True(capturedPayload.Title!.Length <= 250);
|
||||
Assert.EndsWith("...", capturedPayload.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TrimsDeviceNames()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string> { " device1 ", "device2 " },
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("device1,device2", capturedPayload.Device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_SkipsEmptyDevices()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string> { "device1", "", " ", "device2" },
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("device1,device2", capturedPayload.Device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.ThrowsAsync(new PushoverException("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PushoverException>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_MessageContainsOnlyDescription()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description Only",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("Test Description Only", capturedPayload.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -399,13 +399,12 @@ public class QueueRuleMatchTests
|
||||
Assert.False(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
|
||||
private static Mock<ITorrentItemWrapper> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
|
||||
{
|
||||
var torrent = new Mock<ITorrentItem>();
|
||||
var torrent = new Mock<ITorrentItemWrapper>();
|
||||
torrent.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrent.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrent.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrent.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
return torrent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Xunit;
|
||||
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Health;
|
||||
|
||||
public class ApplicationHealthCheckTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(healthCheck);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckHealthAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_DescriptionIndicatesRunning()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("running", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithCancellationToken_CompletesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!, cts.Token);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithContext_CompletesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
var context = new HealthCheckContext();
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_MultipleCalls_AllReturnHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
|
||||
// Act
|
||||
var result1 = await healthCheck.CheckHealthAsync(null!);
|
||||
var result2 = await healthCheck.CheckHealthAsync(null!);
|
||||
var result3 = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result1.Status);
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result2.Status);
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result3.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Basic tests for DatabaseHealthCheck.
|
||||
/// Note: Full integration testing requires a real database since in-memory provider
|
||||
/// doesn't support migrations (GetPendingMigrationsAsync).
|
||||
/// </summary>
|
||||
public class DatabaseHealthCheckTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<DatabaseHealthCheck>> _loggerMock;
|
||||
private DataContext? _dataContext;
|
||||
|
||||
public DatabaseHealthCheckTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DatabaseHealthCheck>>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext?.Dispose();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_dataContext = new DataContext(options);
|
||||
|
||||
// Act
|
||||
var healthCheck = new DatabaseHealthCheck(_dataContext, _loggerMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(healthCheck);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exception Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenDisposedContext_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var disposedContext = new DataContext(options);
|
||||
disposedContext.Dispose();
|
||||
|
||||
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenUnhealthy_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var disposedContext = new DataContext(options);
|
||||
disposedContext.Dispose();
|
||||
|
||||
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
|
||||
|
||||
// Act
|
||||
await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenUnhealthy_DescriptionIndicatesFailure()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var disposedContext = new DataContext(options);
|
||||
disposedContext.Dispose();
|
||||
|
||||
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("failed", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
|
||||
using HealthStatus = Cleanuparr.Infrastructure.Health.HealthStatus;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Health;
|
||||
|
||||
public class DownloadClientsHealthCheckTests
|
||||
{
|
||||
private readonly Mock<IHealthCheckService> _healthCheckServiceMock;
|
||||
private readonly Mock<ILogger<DownloadClientsHealthCheck>> _loggerMock;
|
||||
private readonly DownloadClientsHealthCheck _healthCheck;
|
||||
|
||||
public DownloadClientsHealthCheckTests()
|
||||
{
|
||||
_healthCheckServiceMock = new Mock<IHealthCheckService>();
|
||||
_loggerMock = new Mock<ILogger<DownloadClientsHealthCheck>>();
|
||||
_healthCheck = new DownloadClientsHealthCheck(_healthCheckServiceMock.Object, _loggerMock.Object);
|
||||
}
|
||||
|
||||
#region CheckHealthAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenNoClientsConfigured_ReturnsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(new Dictionary<Guid, HealthStatus>());
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
Assert.Contains("No download clients configured", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenAllClientsHealthy_ReturnsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client2") },
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client3") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
Assert.Contains("All 3 download clients are healthy", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenSomeClientsUnhealthy_ReturnsDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client2") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client3") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Degraded, result.Status);
|
||||
Assert.Contains("1/3", result.Description);
|
||||
Assert.Contains("Client3", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenMajorityUnhealthy_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client3") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("2/3", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenAllUnhealthy_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenServiceThrows_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Throws(new Exception("Service error"));
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("Download clients health check failed", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_IncludesUnhealthyClientNames()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("HealthyClient") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient1") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient2") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("BrokenClient1", result.Description);
|
||||
Assert.Contains("BrokenClient2", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithSingleClient_HandlesCorrectly()
|
||||
{
|
||||
// Arrange - Single healthy client
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("OnlyClient") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithSingleUnhealthyClient_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange - Single unhealthy client (1/1 > 50%)
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static HealthStatus CreateHealthyStatus(string clientName)
|
||||
{
|
||||
return new HealthStatus
|
||||
{
|
||||
IsHealthy = true,
|
||||
ClientName = clientName,
|
||||
ClientId = Guid.NewGuid(),
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ClientTypeName = DownloadClientTypeName.qBittorrent
|
||||
};
|
||||
}
|
||||
|
||||
private static HealthStatus CreateUnhealthyStatus(string clientName)
|
||||
{
|
||||
return new HealthStatus
|
||||
{
|
||||
IsHealthy = false,
|
||||
ClientName = clientName,
|
||||
ClientId = Guid.NewGuid(),
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ErrorMessage = "Connection failed",
|
||||
ClientTypeName = DownloadClientTypeName.qBittorrent
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Health;
|
||||
|
||||
public class HealthCheckBackgroundServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<HealthCheckBackgroundService>> _loggerMock;
|
||||
private readonly Mock<IHealthCheckService> _healthCheckServiceMock;
|
||||
private HealthCheckBackgroundService? _service;
|
||||
|
||||
public HealthCheckBackgroundServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<HealthCheckBackgroundService>>();
|
||||
_healthCheckServiceMock = new Mock<IHealthCheckService>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_service?.Dispose();
|
||||
}
|
||||
|
||||
private HealthCheckBackgroundService CreateService()
|
||||
{
|
||||
_service = new HealthCheckBackgroundService(
|
||||
_loggerMock.Object,
|
||||
_healthCheckServiceMock.Object);
|
||||
return _service;
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var service = CreateService();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenCancelledImmediately_StopsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_CallsCheckAllClientsHealthAsync()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
// Give it some time to execute at least once
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_healthCheckServiceMock.Verify(s => s.CheckAllClientsHealthAsync(), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenAllClientsHealthy_LogsDebugMessage()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client2") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Check that debug log was called (all healthy)
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("healthy")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenSomeClientsUnhealthy_LogsWarningMessage()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2", "Connection failed") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Check that warning log was called for unhealthy clients
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unhealthy")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenHealthCheckThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var callCount = 0;
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
throw new Exception("Health check failed");
|
||||
}
|
||||
return new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") }
|
||||
};
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Error should be logged
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error performing periodic health check")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithNoClients_HandlesEmptyResults()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>();
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Should handle gracefully
|
||||
_healthCheckServiceMock.Verify(s => s.CheckAllClientsHealthAsync(), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_LogsDetailedInfoForUnhealthyClients()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var unhealthyClientId = Guid.NewGuid();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ unhealthyClientId, CreateUnhealthyStatus("UnhealthyClient", "Connection timeout") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Should log details about the unhealthy client
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("UnhealthyClient") ||
|
||||
v.ToString()!.Contains("Connection timeout")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_StartsBackgroundService()
|
||||
{
|
||||
// Arrange
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(new Dictionary<Guid, HealthStatus>());
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
|
||||
// Cleanup
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_StopsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(new Dictionary<Guid, HealthStatus>());
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Act
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Should log stop message
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopped")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static HealthStatus CreateHealthyStatus(string clientName)
|
||||
{
|
||||
return new HealthStatus
|
||||
{
|
||||
IsHealthy = true,
|
||||
ClientName = clientName,
|
||||
ClientId = Guid.NewGuid(),
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ResponseTime = TimeSpan.FromMilliseconds(50),
|
||||
ClientTypeName = DownloadClientTypeName.qBittorrent
|
||||
};
|
||||
}
|
||||
|
||||
private static HealthStatus CreateUnhealthyStatus(string clientName, string errorMessage)
|
||||
{
|
||||
return new HealthStatus
|
||||
{
|
||||
IsHealthy = false,
|
||||
ClientName = clientName,
|
||||
ClientId = Guid.NewGuid(),
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ResponseTime = TimeSpan.Zero,
|
||||
ErrorMessage = errorMessage,
|
||||
ClientTypeName = DownloadClientTypeName.qBittorrent
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// using Common.Configuration;
|
||||
// using Common.Enums;
|
||||
// using Infrastructure.Configuration;
|
||||
// using Infrastructure.Health;
|
||||
// using Infrastructure.Verticals.DownloadClient;
|
||||
// using Infrastructure.Verticals.DownloadClient.Factory;
|
||||
// using Microsoft.Extensions.Logging;
|
||||
// using NSubstitute;
|
||||
// using NSubstitute.ExceptionExtensions;
|
||||
//
|
||||
// namespace Infrastructure.Tests.Health;
|
||||
//
|
||||
// public class HealthCheckServiceFixture : IDisposable
|
||||
// {
|
||||
// public ILogger<HealthCheckService> Logger { get; }
|
||||
// public IConfigManager ConfigManager { get; }
|
||||
// public IDownloadClientFactory ClientFactory { get; }
|
||||
// public IDownloadService MockClient { get; }
|
||||
// public DownloadClientConfigs DownloadClientConfigs { get; }
|
||||
//
|
||||
// public HealthCheckServiceFixture()
|
||||
// {
|
||||
// Logger = Substitute.For<ILogger<HealthCheckService>>();
|
||||
// ConfigManager = Substitute.For<IConfigManager>();
|
||||
// ClientFactory = Substitute.For<IDownloadClientFactory>();
|
||||
// MockClient = Substitute.For<IDownloadService>();
|
||||
// Guid clientId = Guid.NewGuid();
|
||||
//
|
||||
// // Set up test download client config
|
||||
// DownloadClientConfigs = new DownloadClientConfigs
|
||||
// {
|
||||
// Clients = new List<DownloadClientConfig>
|
||||
// {
|
||||
// new()
|
||||
// {
|
||||
// Id = clientId,
|
||||
// Name = "Test QBittorrent",
|
||||
// Type = DownloadClientType.QBittorrent,
|
||||
// Enabled = true,
|
||||
// Username = "admin",
|
||||
// Password = "adminadmin"
|
||||
// },
|
||||
// new()
|
||||
// {
|
||||
// Id = Guid.NewGuid(),
|
||||
// Name = "Test Transmission",
|
||||
// Type = DownloadClientType.Transmission,
|
||||
// Enabled = true,
|
||||
// Username = "admin",
|
||||
// Password = "adminadmin"
|
||||
// },
|
||||
// new()
|
||||
// {
|
||||
// Id = Guid.NewGuid(),
|
||||
// Name = "Disabled Client",
|
||||
// Type = DownloadClientType.QBittorrent,
|
||||
// Enabled = false,
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// // Set up the mock client factory
|
||||
// ClientFactory.GetClient(Arg.Any<Guid>()).Returns(MockClient);
|
||||
// MockClient.GetClientId().Returns(clientId);
|
||||
//
|
||||
// // Set up mock config manager
|
||||
// ConfigManager.GetConfiguration<DownloadClientConfigs>().Returns(DownloadClientConfigs);
|
||||
// }
|
||||
//
|
||||
// public HealthCheckService CreateSut()
|
||||
// {
|
||||
// return new HealthCheckService(Logger, ConfigManager, ClientFactory);
|
||||
// }
|
||||
//
|
||||
// public void SetupHealthyClient(Guid clientId)
|
||||
// {
|
||||
// // Setup a client that will successfully login
|
||||
// MockClient.LoginAsync().Returns(Task.CompletedTask);
|
||||
// }
|
||||
//
|
||||
// public void SetupUnhealthyClient(Guid clientId, string errorMessage = "Failed to connect")
|
||||
// {
|
||||
// // Setup a client that will fail to login
|
||||
// MockClient.LoginAsync().Throws(new Exception(errorMessage));
|
||||
// }
|
||||
//
|
||||
// public void Dispose()
|
||||
// {
|
||||
// // Cleanup if needed
|
||||
// }
|
||||
// }
|
||||
@@ -1,177 +0,0 @@
|
||||
// using Infrastructure.Health;
|
||||
// using NSubstitute;
|
||||
// using Shouldly;
|
||||
//
|
||||
// namespace Infrastructure.Tests.Health;
|
||||
//
|
||||
// public class HealthCheckServiceTests : IClassFixture<HealthCheckServiceFixture>
|
||||
// {
|
||||
// private readonly HealthCheckServiceFixture _fixture;
|
||||
//
|
||||
// public HealthCheckServiceTests(HealthCheckServiceFixture fixture)
|
||||
// {
|
||||
// _fixture = fixture;
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task CheckClientHealthAsync_WithHealthyClient_ShouldReturnHealthyStatus()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Act
|
||||
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.IsHealthy.ShouldBeTrue(),
|
||||
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001")),
|
||||
// () => result.ErrorMessage.ShouldBeNull(),
|
||||
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task CheckClientHealthAsync_WithUnhealthyClient_ShouldReturnUnhealthyStatus()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000001"), "Connection refused");
|
||||
//
|
||||
// // Act
|
||||
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.IsHealthy.ShouldBeFalse(),
|
||||
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001")),
|
||||
// () => result.ErrorMessage?.ShouldContain("Connection refused"),
|
||||
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task CheckClientHealthAsync_WithNonExistentClient_ShouldReturnErrorStatus()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Configure the ConfigManager to return null for the client config
|
||||
// _fixture.ConfigManager.GetConfigurationAsync<DownloadClientConfigs>().Returns(
|
||||
// Task.FromResult<DownloadClientConfigs>(new())
|
||||
// );
|
||||
//
|
||||
// // Act
|
||||
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000010"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.IsHealthy.ShouldBeFalse(),
|
||||
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000010")),
|
||||
// () => result.ErrorMessage?.ShouldContain("not found"),
|
||||
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task CheckAllClientsHealthAsync_ShouldReturnAllEnabledClients()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
//
|
||||
// // Act
|
||||
// var results = await sut.CheckAllClientsHealthAsync();
|
||||
//
|
||||
// // Assert
|
||||
// results.Count.ShouldBe(2); // Only enabled clients
|
||||
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
// results[new Guid("00000000-0000-0000-0000-000000000001")].IsHealthy.ShouldBeTrue();
|
||||
// results[new Guid("00000000-0000-0000-0000-000000000002")].IsHealthy.ShouldBeFalse();
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task ClientHealthChanged_ShouldRaiseEventOnHealthStateChange()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// ClientHealthChangedEventArgs? capturedArgs = null;
|
||||
// sut.ClientHealthChanged += (_, args) => capturedArgs = args;
|
||||
//
|
||||
// // Act - first check establishes initial state
|
||||
// var firstResult = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Setup client to be unhealthy for second check
|
||||
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Act - second check changes state
|
||||
// var secondResult = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// capturedArgs.ShouldNotBeNull();
|
||||
// capturedArgs.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// capturedArgs.Status.IsHealthy.ShouldBeFalse();
|
||||
// capturedArgs.IsDegraded.ShouldBeTrue();
|
||||
// capturedArgs.IsRecovered.ShouldBeFalse();
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task GetClientHealth_ShouldReturnCachedStatus()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Perform a check to cache the status
|
||||
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Act
|
||||
// var result = sut.GetClientHealth(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldNotBeNull();
|
||||
// result.IsHealthy.ShouldBeTrue();
|
||||
// result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void GetClientHealth_WithNoCheck_ShouldReturnNull()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Act
|
||||
// var result = sut.GetClientHealth(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldBeNull();
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task GetAllClientHealth_ShouldReturnAllCheckedClients()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
//
|
||||
// // Perform checks to cache statuses
|
||||
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
//
|
||||
// // Act
|
||||
// var results = sut.GetAllClientHealth();
|
||||
//
|
||||
// // Assert
|
||||
// results.Count.ShouldBe(2);
|
||||
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
// results[new Guid("00000000-0000-0000-0000-000000000001")].IsHealthy.ShouldBeTrue();
|
||||
// results[new Guid("00000000-0000-0000-0000-000000000002")].IsHealthy.ShouldBeFalse();
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,148 @@
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Models;
|
||||
|
||||
public class ValidationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_ReturnsValidResult()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Success();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_HasEmptyErrorMessage()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Success();
|
||||
|
||||
// Assert
|
||||
result.ErrorMessage.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_HasEmptyDetails()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Success();
|
||||
|
||||
// Assert
|
||||
result.Details.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_ReturnsInvalidResult()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Failure("Error occurred");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_ContainsErrorMessage()
|
||||
{
|
||||
// Arrange
|
||||
const string errorMessage = "Validation failed";
|
||||
|
||||
// Act
|
||||
var result = ValidationResult.Failure(errorMessage);
|
||||
|
||||
// Assert
|
||||
result.ErrorMessage.ShouldBe(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithDetails_ContainsAllDetails()
|
||||
{
|
||||
// Arrange
|
||||
const string errorMessage = "Multiple errors";
|
||||
var details = new List<string> { "Error 1", "Error 2", "Error 3" };
|
||||
|
||||
// Act
|
||||
var result = ValidationResult.Failure(errorMessage, details);
|
||||
|
||||
// Assert
|
||||
result.Details.Count.ShouldBe(3);
|
||||
result.Details.ShouldContain("Error 1");
|
||||
result.Details.ShouldContain("Error 2");
|
||||
result.Details.ShouldContain("Error 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithoutDetails_HasEmptyDetailsList()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Failure("Error");
|
||||
|
||||
// Assert
|
||||
result.Details.ShouldNotBeNull();
|
||||
result.Details.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithNullDetails_HasEmptyDetailsList()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Failure("Error", null);
|
||||
|
||||
// Assert
|
||||
result.Details.ShouldNotBeNull();
|
||||
result.Details.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_IsValidIsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = new ValidationResult();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_ErrorMessageIsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = new ValidationResult();
|
||||
|
||||
// Assert
|
||||
result.ErrorMessage.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_DetailsIsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = new ValidationResult();
|
||||
|
||||
// Assert
|
||||
result.Details.ShouldNotBeNull();
|
||||
result.Details.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_CanBeSetDirectly()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ValidationResult();
|
||||
|
||||
// Act
|
||||
result.IsValid = true;
|
||||
result.ErrorMessage = "Test error";
|
||||
result.Details = new List<string> { "Detail 1" };
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.ErrorMessage.ShouldBe("Test error");
|
||||
result.Details.ShouldContain("Detail 1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Entities.AppStatus;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class AppStatusRefreshServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<AppStatusRefreshService>> _loggerMock;
|
||||
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly AppStatusSnapshot _snapshot;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private AppStatusRefreshService? _service;
|
||||
|
||||
public AppStatusRefreshServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<AppStatusRefreshService>>();
|
||||
_hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
_snapshot = new AppStatusSnapshot();
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
|
||||
// Setup hub context
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
var clientProxyMock = new Mock<IClientProxy>();
|
||||
clientsMock.Setup(c => c.All).Returns(clientProxyMock.Object);
|
||||
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_service?.Dispose();
|
||||
}
|
||||
|
||||
private AppStatusRefreshService CreateService()
|
||||
{
|
||||
_service = new AppStatusRefreshService(
|
||||
_loggerMock.Object,
|
||||
_hubContextMock.Object,
|
||||
_httpClientFactoryMock.Object,
|
||||
_snapshot,
|
||||
_jsonOptions);
|
||||
return _service;
|
||||
}
|
||||
|
||||
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Content = new StringContent(content, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(_httpHandlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsAllDependencies()
|
||||
{
|
||||
// Act
|
||||
var service = CreateService();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AppStatusSnapshot Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateLatestVersion_ChangesStatusReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
|
||||
// Act
|
||||
var result = snapshot.UpdateLatestVersion("1.0.0", out var status);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal("1.0.0", status.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateLatestVersion_SameVersionReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
snapshot.UpdateLatestVersion("1.0.0", out _);
|
||||
|
||||
// Act
|
||||
var result = snapshot.UpdateLatestVersion("1.0.0", out var status);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal("1.0.0", status.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateCurrentVersion_ChangesStatusReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
|
||||
// Act
|
||||
var result = snapshot.UpdateCurrentVersion("2.0.0", out var status);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal("2.0.0", status.CurrentVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_Current_ReturnsCurrentState()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
snapshot.UpdateCurrentVersion("1.0.0", out _);
|
||||
snapshot.UpdateLatestVersion("2.0.0", out _);
|
||||
|
||||
// Act
|
||||
var current = snapshot.Current;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("1.0.0", current.CurrentVersion);
|
||||
Assert.Equal("2.0.0", current.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateWithNull_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
snapshot.UpdateLatestVersion("1.0.0", out _);
|
||||
|
||||
// Act
|
||||
var result = snapshot.UpdateLatestVersion(null, out var status);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(status.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateWithSameNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
|
||||
// Act - Both are null initially
|
||||
var result = snapshot.UpdateLatestVersion(null, out _);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Quartz;
|
||||
using Quartz.Impl.Matchers;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class JobManagementServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<JobManagementService>> _loggerMock;
|
||||
private readonly Mock<ISchedulerFactory> _schedulerFactoryMock;
|
||||
private readonly Mock<IScheduler> _schedulerMock;
|
||||
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
|
||||
private readonly JobManagementService _service;
|
||||
|
||||
public JobManagementServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<JobManagementService>>();
|
||||
_schedulerFactoryMock = new Mock<ISchedulerFactory>();
|
||||
_schedulerMock = new Mock<IScheduler>();
|
||||
_hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
|
||||
_schedulerFactoryMock.Setup(f => f.GetScheduler(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_schedulerMock.Object);
|
||||
|
||||
_service = new JobManagementService(_loggerMock.Object, _schedulerFactoryMock.Object, _hubContextMock.Object);
|
||||
}
|
||||
|
||||
#region StartJob Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WithInvalidDirectCronExpression_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var invalidCron = "invalid-cron";
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: invalidCron);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_JobDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("does not exist")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WithValidCronExpression_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
_schedulerMock.Verify(s => s.ResumeJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WithSchedule_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, schedule: schedule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WithNoScheduleOrCron_CreatesOneTimeTrigger()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.DownloadCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(
|
||||
It.Is<ITrigger>(t => t.Key.Name.Contains("onetime")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_CleansUpExistingTriggers_BeforeSchedulingNew()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var cronExpression = "0 0/5 * * * ?";
|
||||
|
||||
var existingTriggerMock = new Mock<ITrigger>();
|
||||
existingTriggerMock.Setup(t => t.Key).Returns(new TriggerKey("existing-trigger"));
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger> { existingTriggerMock.Object });
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.UnscheduleJob(
|
||||
It.Is<TriggerKey>(k => k.Name == "existing-trigger"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WhenSchedulerThrows_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var cronExpression = "0 0/5 * * * ?";
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StopJob Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StopJob_JobDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.StopJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopJob_JobExists_CleansUpTriggersAndReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
|
||||
var triggerMock = new Mock<ITrigger>();
|
||||
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
|
||||
|
||||
// Act
|
||||
var result = await _service.StopJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.UnscheduleJob(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopJob_WhenSchedulerThrows_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.StopJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetJob Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetJob_JobDoesNotExist_ReturnsNotFoundStatus()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Not Found", result.Status);
|
||||
Assert.Equal("QueueCleaner", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJob_JobExistsNoTriggers_ReturnsNotScheduledStatus()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Not Scheduled", result.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TriggerState.Normal, "Scheduled")]
|
||||
[InlineData(TriggerState.Paused, "Paused")]
|
||||
[InlineData(TriggerState.Complete, "Complete")]
|
||||
[InlineData(TriggerState.Error, "Error")]
|
||||
[InlineData(TriggerState.Blocked, "Running")]
|
||||
[InlineData(TriggerState.None, "Not Scheduled")]
|
||||
public async Task GetJob_WithTrigger_ReturnsCorrectStatus(TriggerState triggerState, string expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
var triggerMock = new Mock<ITrigger>();
|
||||
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
|
||||
triggerMock.Setup(t => t.GetNextFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
triggerMock.Setup(t => t.GetPreviousFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(-5));
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
|
||||
_schedulerMock.Setup(s => s.GetTriggerState(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(triggerState);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedStatus, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJob_WhenSchedulerThrows_ReturnsErrorStatus()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error", result.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllJobs Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllJobs_NoJobs_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAllJobs();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllJobs_WithJobs_ReturnsJobList()
|
||||
{
|
||||
// Arrange
|
||||
var jobKey = new JobKey("QueueCleaner");
|
||||
var triggerMock = new Mock<ITrigger>();
|
||||
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
|
||||
triggerMock.Setup(t => t.GetNextFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
|
||||
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "DEFAULT" });
|
||||
_schedulerMock.Setup(s => s.GetJobKeys(It.IsAny<GroupMatcher<JobKey>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new HashSet<JobKey> { jobKey });
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
|
||||
_schedulerMock.Setup(s => s.GetTriggerState(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TriggerState.Normal);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAllJobs();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("QueueCleaner", result[0].Name);
|
||||
Assert.Equal("Scheduled", result[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllJobs_WhenSchedulerThrows_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAllJobs();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TriggerJobOnce Tests
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobOnce_JobDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.TriggerJobOnce(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobOnce_JobExists_TriggersJobAndReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.TriggerJobOnce(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(
|
||||
It.Is<ITrigger>(t => t.Key.Name.Contains("immediate") && t.Key.Name.Contains("manual")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobOnce_WhenSchedulerThrows_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.TriggerJobOnce(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateJobSchedule Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateJobSchedule_NullSchedule_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => _service.UpdateJobSchedule(jobType, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateJobSchedule_JobDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateJobSchedule(jobType, schedule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateJobSchedule_ValidSchedule_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.DownloadCleaner;
|
||||
var schedule = new JobSchedule { Every = 10, Type = ScheduleUnit.Minutes };
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateJobSchedule(jobType, schedule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateJobSchedule_WhenSchedulerThrows_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateJobSchedule(jobType, schedule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetMainTrigger Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_TriggerExists_ReturnsTrigger()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger");
|
||||
|
||||
var triggerMock = new Mock<ITrigger>();
|
||||
triggerMock.Setup(t => t.Key).Returns(expectedTriggerKey);
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTrigger(expectedTriggerKey, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(triggerMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedTriggerKey, result.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public class RuleEvaluatorTests
|
||||
};
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -56,13 +56,12 @@ public class RuleEvaluatorTests
|
||||
|
||||
long downloadedBytes = 0;
|
||||
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
var torrentMock = new Mock<ITorrentItemWrapper>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns("hash");
|
||||
torrentMock.SetupGet(t => t.Name).Returns("Example Torrent");
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(false);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse("100 MB").Bytes);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
|
||||
|
||||
// Seed cache with initial observation (no reset expected)
|
||||
@@ -91,7 +90,7 @@ public class RuleEvaluatorTests
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns((StallRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -115,7 +114,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -144,7 +143,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -172,7 +171,7 @@ public class RuleEvaluatorTests
|
||||
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
@@ -197,7 +196,7 @@ public class RuleEvaluatorTests
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns((SlowRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -221,7 +220,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -249,7 +248,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -277,7 +276,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -304,7 +303,7 @@ public class RuleEvaluatorTests
|
||||
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
@@ -336,7 +335,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -374,7 +373,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 2);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -408,7 +407,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -437,7 +436,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -469,7 +468,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -497,7 +496,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 2);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -525,7 +524,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -559,7 +558,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 1);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -588,7 +587,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -620,7 +619,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -644,7 +643,7 @@ public class RuleEvaluatorTests
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrentMock(
|
||||
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
|
||||
Func<long>? downloadedBytesFactory = null,
|
||||
bool isPrivate = false,
|
||||
string hash = "hash",
|
||||
@@ -652,13 +651,12 @@ public class RuleEvaluatorTests
|
||||
double completionPercentage = 50,
|
||||
string size = "100 MB")
|
||||
{
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
var torrentMock = new Mock<ITorrentItemWrapper>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns(hash);
|
||||
torrentMock.SetupGet(t => t.Name).Returns(name);
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytesFactory?.Invoke() ?? 0);
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(7200);
|
||||
@@ -720,7 +718,7 @@ public class RuleEvaluatorTests
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns((StallRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -745,7 +743,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -774,7 +772,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -803,7 +801,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -830,7 +828,7 @@ public class RuleEvaluatorTests
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns((SlowRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -855,7 +853,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -884,7 +882,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -919,7 +917,7 @@ public class RuleEvaluatorTests
|
||||
deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -949,7 +947,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
|
||||
@@ -379,18 +379,17 @@ public class RuleManagerTests
|
||||
Assert.Equal(slowRule.Id, result.Id);
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrentMock(
|
||||
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
|
||||
bool isPrivate = false,
|
||||
double completionPercentage = 50,
|
||||
string size = "100 MB")
|
||||
{
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
var torrentMock = new Mock<ITorrentItemWrapper>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns("test-hash");
|
||||
torrentMock.SetupGet(t => t.Name).Returns("Test Torrent");
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(0);
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(3600);
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class StrikerTests : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<Striker> _logger;
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly Striker _striker;
|
||||
|
||||
public StrikerTests()
|
||||
{
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_logger = Substitute.For<ILogger<Striker>>();
|
||||
|
||||
// Create EventPublisher with mocked dependencies
|
||||
var eventsContext = CreateMockEventsContext();
|
||||
var hubContext = Substitute.For<IHubContext<AppHub>>();
|
||||
var hubClients = Substitute.For<IHubClients>();
|
||||
var clientProxy = Substitute.For<IClientProxy>();
|
||||
hubContext.Clients.Returns(hubClients);
|
||||
hubClients.All.Returns(clientProxy);
|
||||
|
||||
var eventLogger = Substitute.For<ILogger<EventPublisher>>();
|
||||
var notificationPublisher = Substitute.For<INotificationPublisher>();
|
||||
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
|
||||
// Configure dry run interceptor to just complete the task (we don't need actual DB saves in tests)
|
||||
dryRunInterceptor
|
||||
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_eventPublisher = new EventPublisher(
|
||||
eventsContext,
|
||||
hubContext,
|
||||
eventLogger,
|
||||
notificationPublisher,
|
||||
dryRunInterceptor);
|
||||
|
||||
_striker = new Striker(_logger, _cache, _eventPublisher);
|
||||
|
||||
// Clear static state before each test
|
||||
Striker.RecurringHashes.Clear();
|
||||
|
||||
// Set up required context for recurring item events and FailedImport strikes
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
|
||||
ContextProvider.Set("ArrInstanceUrl", new Uri("http://localhost:8989"));
|
||||
ContextProvider.Set(new QueueRecord
|
||||
{
|
||||
Title = "Test Item",
|
||||
DownloadId = "test-download-id",
|
||||
Protocol = "torrent",
|
||||
Id = 1,
|
||||
StatusMessages = []
|
||||
});
|
||||
}
|
||||
|
||||
private static EventsContext CreateMockEventsContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new EventsContext(options);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_FirstStrike_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 3;
|
||||
|
||||
// Act
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_ReachesMaxStrikes_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 3;
|
||||
|
||||
// Act - Strike 3 times
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_ExceedsMaxStrikes_ReturnsTrue_AndAddsToRecurringHashes()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "ABC123";
|
||||
const string itemName = "Recurring Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Act - Strike 3 times (exceeds max of 2)
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_DifferentStrikeTypes_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Act - Strike with different types
|
||||
var stalledResult1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var slowSpeedResult1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
|
||||
var stalledResult2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var slowSpeedResult2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
|
||||
|
||||
// Assert - Both should reach max independently
|
||||
stalledResult1.ShouldBeFalse();
|
||||
slowSpeedResult1.ShouldBeFalse();
|
||||
stalledResult2.ShouldBeTrue(); // 2nd stalled strike = maxStrikes
|
||||
slowSpeedResult2.ShouldBeTrue(); // 2nd slow speed strike = maxStrikes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_SameHash_AccumulatesStrikes()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 5;
|
||||
|
||||
// Act - Strike 4 times
|
||||
var result1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result3 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result4 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert - None should trigger removal yet (need 5)
|
||||
result1.ShouldBeFalse();
|
||||
result2.ShouldBeFalse();
|
||||
result3.ShouldBeFalse();
|
||||
result4.ShouldBeFalse();
|
||||
|
||||
// 5th strike should trigger removal
|
||||
var result5 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
result5.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStrikeAsync_ClearsStrikeCount()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 3;
|
||||
|
||||
// Strike twice
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Act - Reset strikes
|
||||
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled);
|
||||
|
||||
// Assert - Next strike should be treated as first (returns false)
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStrikeAsync_OnlyResetsSpecifiedType()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Strike with both types
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
|
||||
|
||||
// Act - Reset only Stalled strikes
|
||||
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled);
|
||||
|
||||
// Assert - Stalled should be reset (1st strike = false), SlowSpeed should continue (2nd strike = true)
|
||||
var stalledResult = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var slowSpeedResult = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
|
||||
|
||||
stalledResult.ShouldBeFalse(); // Reset, so this is strike #1
|
||||
slowSpeedResult.ShouldBeTrue(); // Not reset, so this is strike #2 = maxStrikes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_ZeroMaxStrikes_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 0;
|
||||
|
||||
// Act
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert - Should return false immediately without striking
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)2, 0, false)] // Strike 1, max 2 -> below limit (1 < 2)
|
||||
[InlineData((ushort)2, 1, true)] // Strike 2, max 2 -> at limit (2 >= 2)
|
||||
[InlineData((ushort)3, 1, false)] // Strike 2, max 3 -> below limit (2 < 3)
|
||||
[InlineData((ushort)3, 2, true)] // Strike 3, max 3 -> at limit (3 >= 3)
|
||||
[InlineData((ushort)1, 0, true)] // Strike 1, max 1 -> at limit (1 >= 1)
|
||||
public async Task StrikeAndCheckLimit_BoundaryConditions(ushort maxStrikes, int preStrikes, bool expectedResult)
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "boundary-test";
|
||||
const string itemName = "Boundary Test Item";
|
||||
|
||||
// Pre-strike
|
||||
for (int i = 0; i < preStrikes; i++)
|
||||
{
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedResult);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StrikeType.Stalled)]
|
||||
[InlineData(StrikeType.DownloadingMetadata)]
|
||||
[InlineData(StrikeType.FailedImport)]
|
||||
[InlineData(StrikeType.SlowSpeed)]
|
||||
[InlineData(StrikeType.SlowTime)]
|
||||
public async Task StrikeAndCheckLimit_AllStrikeTypes_WorkCorrectly(StrikeType strikeType)
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "type-test";
|
||||
const string itemName = "Type Test Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Act
|
||||
var result1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, strikeType);
|
||||
var result2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, strikeType);
|
||||
|
||||
// Assert
|
||||
result1.ShouldBeFalse();
|
||||
result2.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_DifferentHashes_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
const string hash1 = "hash1";
|
||||
const string hash2 = "hash2";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Act - Strike hash1 twice, hash2 once
|
||||
await _striker.StrikeAndCheckLimit(hash1, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash2, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var hash1Result = await _striker.StrikeAndCheckLimit(hash1, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var hash2Result = await _striker.StrikeAndCheckLimit(hash2, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
hash1Result.ShouldBeTrue(); // hash1 reached max (2 strikes)
|
||||
hash2Result.ShouldBeTrue(); // hash2 reached max (2 strikes)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStrikeAsync_NonExistentStrike_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "never-struck";
|
||||
const string itemName = "Never Struck Item";
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_RecurringItem_OnlyAddedOnceToRecurringHashes()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "recurring-hash";
|
||||
const string itemName = "Recurring Item";
|
||||
const ushort maxStrikes = 1;
|
||||
|
||||
// Act - Strike multiple times past the limit
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert - Hash should only appear once in RecurringHashes
|
||||
Striker.RecurringHashes.Count.ShouldBe(1);
|
||||
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Utilities;
|
||||
|
||||
public class CronExpressionConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConvertToCronExpression_Seconds_ReturnsCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = 30, Type = ScheduleUnit.Seconds };
|
||||
|
||||
// Act
|
||||
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe("0/30 * * ? * * *");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "0 0/1 * ? * * *")]
|
||||
[InlineData(5, "0 0/5 * ? * * *")]
|
||||
[InlineData(10, "0 0/10 * ? * * *")]
|
||||
[InlineData(15, "0 0/15 * ? * * *")]
|
||||
[InlineData(30, "0 0/30 * ? * * *")]
|
||||
public void ConvertToCronExpression_Minutes_ReturnsCorrectFormat(int minutes, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = minutes, Type = ScheduleUnit.Minutes };
|
||||
|
||||
// Act
|
||||
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "0 0 0/1 ? * * *")]
|
||||
[InlineData(2, "0 0 0/2 ? * * *")]
|
||||
[InlineData(4, "0 0 0/4 ? * * *")]
|
||||
[InlineData(6, "0 0 0/6 ? * * *")]
|
||||
[InlineData(12, "0 0 0/12 ? * * *")]
|
||||
public void ConvertToCronExpression_Hours_ReturnsCorrectFormat(int hours, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = hours, Type = ScheduleUnit.Hours };
|
||||
|
||||
// Act
|
||||
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0 */5 * * * ?")]
|
||||
[InlineData("0 0 */2 * * ?")]
|
||||
[InlineData("0/30 * * ? * * *")]
|
||||
public void IsValidCronExpression_ValidQuartzCron_ReturnsTrue(string cronExpression)
|
||||
{
|
||||
// Act
|
||||
var result = CronExpressionConverter.IsValidCronExpression(cronExpression);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("* * *")]
|
||||
[InlineData("not a cron")]
|
||||
[InlineData("0 0 0 0 0 0 0")]
|
||||
public void IsValidCronExpression_InvalidCron_ReturnsFalse(string cronExpression)
|
||||
{
|
||||
// Act
|
||||
var result = CronExpressionConverter.IsValidCronExpression(cronExpression);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
[InlineData(6)]
|
||||
[InlineData(10)]
|
||||
[InlineData(12)]
|
||||
[InlineData(15)]
|
||||
[InlineData(20)]
|
||||
[InlineData(30)]
|
||||
public void ConvertToCronExpression_AllValidMinuteValues_Succeeds(int minutes)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = minutes, Type = ScheduleUnit.Minutes };
|
||||
|
||||
// Act & Assert
|
||||
Should.NotThrow(() => CronExpressionConverter.ConvertToCronExpression(schedule));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(6)]
|
||||
[InlineData(8)]
|
||||
[InlineData(12)]
|
||||
public void ConvertToCronExpression_AllValidHourValues_Succeeds(int hours)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = hours, Type = ScheduleUnit.Hours };
|
||||
|
||||
// Act & Assert
|
||||
Should.NotThrow(() => CronExpressionConverter.ConvertToCronExpression(schedule));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7, ScheduleUnit.Minutes)] // 7 doesn't divide 60 evenly
|
||||
[InlineData(45, ScheduleUnit.Minutes)] // 45 is not in the valid list
|
||||
[InlineData(5, ScheduleUnit.Hours)] // 5 doesn't divide 24 evenly
|
||||
[InlineData(7, ScheduleUnit.Hours)] // 7 doesn't divide 24 evenly
|
||||
[InlineData(15, ScheduleUnit.Seconds)] // Only 30 seconds is valid
|
||||
public void ConvertToCronExpression_InvalidValue_ThrowsValidationException(int value, ScheduleUnit unit)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = value, Type = unit };
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ValidationException>(() => CronExpressionConverter.ConvertToCronExpression(schedule));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
[InlineData("0 0 0 32 1 ?")] // Invalid day of month (32)
|
||||
[InlineData("0 0 0 ? 13 *")] // Invalid month (13)
|
||||
[InlineData("0 60 * ? * *")] // Invalid minute (60)
|
||||
[InlineData("0 0 25 ? * *")] // Invalid hour (25)
|
||||
[InlineData("0 0 0 ? * 8")] // Invalid day of week (8)
|
||||
public void IsValidCronExpression_InvalidInput_ReturnsFalse(string? cronExpression)
|
||||
{
|
||||
// Act
|
||||
var result = CronExpressionConverter.IsValidCronExpression(cronExpression!);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToCronExpression_NullSchedule_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => CronExpressionConverter.ConvertToCronExpression(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Utilities;
|
||||
|
||||
public class CronValidationHelperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("0 */5 * * * ?")] // Every 5 minutes
|
||||
[InlineData("0 0 */2 * * ?")] // Every 2 hours
|
||||
[InlineData("0 0 0/4 * * ?")] // Every 4 hours
|
||||
[InlineData("*/30 * * * * ?")] // Every 30 seconds
|
||||
public void ValidateCronExpression_ValidExpression_DoesNotThrow(string cronExpression)
|
||||
{
|
||||
// Act & Assert
|
||||
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("* * *")]
|
||||
[InlineData("0 0 0 0 0 0 0")]
|
||||
[InlineData("not a cron")]
|
||||
public void ValidateCronExpression_InvalidSyntax_ThrowsValidationException(string cronExpression)
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Should.Throw<ValidationException>(
|
||||
() => CronValidationHelper.ValidateCronExpression(cronExpression));
|
||||
exception.Message.ShouldContain("Invalid cron expression");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ValidateCronExpression_NullOrEmpty_ThrowsValidationException(string? cronExpression)
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Should.Throw<ValidationException>(
|
||||
() => CronValidationHelper.ValidateCronExpression(cronExpression!));
|
||||
exception.Message.ShouldContain("cannot be null or empty");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("*/1 * * * * ?")] // Every 1 second
|
||||
[InlineData("*/5 * * * * ?")] // Every 5 seconds
|
||||
[InlineData("*/10 * * * * ?")] // Every 10 seconds
|
||||
[InlineData("*/15 * * * * ?")] // Every 15 seconds
|
||||
public void ValidateCronExpression_TriggersTooFast_ThrowsValidationException(string cronExpression)
|
||||
{
|
||||
// Act & Assert - minimum is 30 seconds
|
||||
var exception = Should.Throw<ValidationException>(
|
||||
() => CronValidationHelper.ValidateCronExpression(cronExpression));
|
||||
exception.Message.ShouldContain("minimum");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0 0 0 * * ?")] // Once per day (24 hours)
|
||||
[InlineData("0 0 0 1 * ?")] // Once per month
|
||||
public void ValidateCronExpression_TriggersTooSlow_ThrowsValidationException(string cronExpression)
|
||||
{
|
||||
// Act & Assert - maximum is 6 hours
|
||||
var exception = Should.Throw<ValidationException>(
|
||||
() => CronValidationHelper.ValidateCronExpression(cronExpression));
|
||||
exception.Message.ShouldContain("maximum");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("*/1 * * * * ?")] // Every 1 second - too fast for other jobs
|
||||
[InlineData("*/5 * * * * ?")] // Every 5 seconds - too fast for other jobs
|
||||
public void ValidateCronExpression_MalwareBlocker_HasDifferentLimits(string cronExpression)
|
||||
{
|
||||
// Act & Assert - MalwareBlocker allows faster triggers (no minimum)
|
||||
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression, JobType.MalwareBlocker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCronExpression_AtExactMinimumInterval_DoesNotThrow()
|
||||
{
|
||||
// Arrange - exactly 30 seconds
|
||||
const string cronExpression = "*/30 * * * * ?";
|
||||
|
||||
// Act & Assert
|
||||
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCronExpression_AtExactMaximumInterval_DoesNotThrow()
|
||||
{
|
||||
// Arrange - exactly 6 hours
|
||||
const string cronExpression = "0 0 */6 * * ?";
|
||||
|
||||
// Act & Assert
|
||||
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCronExpression_NullJobType_UsesDefaultLimits()
|
||||
{
|
||||
// Arrange - 5 seconds would fail default limits but pass MalwareBlocker
|
||||
const string cronExpression = "*/5 * * * * ?";
|
||||
|
||||
// Act & Assert - should fail because null uses default limits (30 second minimum)
|
||||
var exception = Should.Throw<ValidationException>(
|
||||
() => CronValidationHelper.ValidateCronExpression(cronExpression, null));
|
||||
exception.Message.ShouldContain("minimum");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(JobType.QueueCleaner)]
|
||||
[InlineData(JobType.DownloadCleaner)]
|
||||
[InlineData(JobType.BlacklistSynchronizer)]
|
||||
public void ValidateCronExpression_NonMalwareBlockerJobs_EnforceMinimumLimit(JobType jobType)
|
||||
{
|
||||
// Arrange - 5 seconds is below minimum
|
||||
const string cronExpression = "*/5 * * * * ?";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Should.Throw<ValidationException>(
|
||||
() => CronValidationHelper.ValidateCronExpression(cronExpression, jobType));
|
||||
exception.Message.ShouldContain("minimum");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0 0 */1 * * ?")] // Every 1 hour
|
||||
[InlineData("0 */30 * * * ?")] // Every 30 minutes
|
||||
[InlineData("0 */1 * * * ?")] // Every 1 minute
|
||||
public void ValidateCronExpression_WithinValidRange_DoesNotThrow(string cronExpression)
|
||||
{
|
||||
// Act & Assert
|
||||
Should.NotThrow(() => CronValidationHelper.ValidateCronExpression(cronExpression));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Utilities;
|
||||
|
||||
public class ScheduleOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetValidValues_Seconds_Returns30()
|
||||
{
|
||||
// Act
|
||||
var result = ScheduleOptions.GetValidValues(ScheduleUnit.Seconds);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(new[] { 30 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetValidValues_Minutes_ReturnsDivisorsOf60()
|
||||
{
|
||||
// Act
|
||||
var result = ScheduleOptions.GetValidValues(ScheduleUnit.Minutes);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(new[] { 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetValidValues_Hours_ReturnsDivisorsOf24()
|
||||
{
|
||||
// Act
|
||||
var result = ScheduleOptions.GetValidValues(ScheduleUnit.Hours);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(new[] { 1, 2, 3, 4, 6, 8, 12 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidValue_Seconds_30_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Seconds, 30);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(15)]
|
||||
[InlineData(45)]
|
||||
[InlineData(60)]
|
||||
public void IsValidValue_Seconds_InvalidValues_ReturnsFalse(int value)
|
||||
{
|
||||
// Act
|
||||
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Seconds, value);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7)]
|
||||
[InlineData(8)]
|
||||
[InlineData(9)]
|
||||
[InlineData(11)]
|
||||
[InlineData(45)]
|
||||
[InlineData(60)]
|
||||
public void IsValidValue_Minutes_InvalidValues_ReturnsFalse(int value)
|
||||
{
|
||||
// 7 doesn't divide 60 evenly, and other values are not in the valid list
|
||||
// Act
|
||||
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Minutes, value);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData(7)]
|
||||
[InlineData(9)]
|
||||
[InlineData(10)]
|
||||
[InlineData(11)]
|
||||
[InlineData(24)]
|
||||
public void IsValidValue_Hours_InvalidValues_ReturnsFalse(int value)
|
||||
{
|
||||
// These don't divide 24 evenly or aren't in valid list
|
||||
// Act
|
||||
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Hours, value);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetValidValues_InvalidUnit_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidUnit = (ScheduleUnit)999;
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => ScheduleOptions.GetValidValues(invalidUnit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidValue_InvalidUnit_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidUnit = (ScheduleUnit)999;
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => ScheduleOptions.IsValidValue(invalidUnit, 1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
[InlineData(6)]
|
||||
[InlineData(10)]
|
||||
[InlineData(12)]
|
||||
[InlineData(15)]
|
||||
[InlineData(20)]
|
||||
[InlineData(30)]
|
||||
public void IsValidValue_Minutes_AllValidValues_ReturnsTrue(int value)
|
||||
{
|
||||
// Act
|
||||
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Minutes, value);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(6)]
|
||||
[InlineData(8)]
|
||||
[InlineData(12)]
|
||||
public void IsValidValue_Hours_AllValidValues_ReturnsTrue(int value)
|
||||
{
|
||||
// Act
|
||||
var result = ScheduleOptions.IsValidValue(ScheduleUnit.Hours, value);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidSecondValues_ContainsOnly30()
|
||||
{
|
||||
// Assert
|
||||
ScheduleOptions.ValidSecondValues.Length.ShouldBe(1);
|
||||
ScheduleOptions.ValidSecondValues.ShouldContain(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidMinuteValues_AllDivide60Evenly()
|
||||
{
|
||||
// Assert - all valid minute values should divide 60 evenly
|
||||
foreach (var value in ScheduleOptions.ValidMinuteValues)
|
||||
{
|
||||
(60 % value).ShouldBe(0, $"Value {value} does not divide 60 evenly");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidHourValues_AllDivide24Evenly()
|
||||
{
|
||||
// Assert - all valid hour values should divide 24 evenly
|
||||
foreach (var value in ScheduleOptions.ValidHourValues)
|
||||
{
|
||||
(24 % value).ShouldBe(0, $"Value {value} does not divide 24 evenly");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1,
|
||||
"methodDisplay": "classAndMethod",
|
||||
"diagnosticMessages": false,
|
||||
"parallelAlgorithm": "aggressive"
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
@@ -17,7 +18,7 @@ namespace Cleanuparr.Infrastructure.Events;
|
||||
/// <summary>
|
||||
/// Service for publishing events to database and SignalR hub
|
||||
/// </summary>
|
||||
public class EventPublisher
|
||||
public class EventPublisher : IEventPublisher
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly IHubContext<AppHub> _appHubContext;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
|
||||
public interface IEventPublisher
|
||||
{
|
||||
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null);
|
||||
|
||||
Task PublishManualAsync(string message, EventSeverity severity, object? data = null);
|
||||
|
||||
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName);
|
||||
|
||||
Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason);
|
||||
|
||||
Task PublishDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
|
||||
|
||||
Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false);
|
||||
|
||||
Task PublishRecurringItem(string hash, string itemName, int strikeCount);
|
||||
|
||||
Task PublishSearchNotTriggered(string hash, string itemName);
|
||||
}
|
||||
@@ -31,13 +31,41 @@ public static class TransmissionExtensions
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string GetCategory(this TorrentInfo download)
|
||||
public static string GetCategory(this TorrentInfo torrent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.DownloadDir))
|
||||
if (string.IsNullOrEmpty(torrent.DownloadDir))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir));
|
||||
return Path.GetFileName(Path.TrimEndingDirectorySeparator(torrent.DownloadDir));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a category to the download directory of the torrent.
|
||||
/// </summary>
|
||||
public static void AppendCategory(this TorrentInfo torrent, string category)
|
||||
{
|
||||
if (string.IsNullOrEmpty(category))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
torrent.DownloadDir = torrent.GetNewLocationByAppend(category);
|
||||
}
|
||||
|
||||
public static string GetNewLocationByAppend(this TorrentInfo torrent, string category)
|
||||
{
|
||||
if (string.IsNullOrEmpty(category))
|
||||
{
|
||||
throw new ArgumentException("Category cannot be null or empty", nameof(category));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(torrent.DownloadDir))
|
||||
{
|
||||
throw new ArgumentException("DownloadDir cannot be null or empty", nameof(torrent.DownloadDir));
|
||||
}
|
||||
|
||||
return string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.DownloadDir, category).Split(['\\', '/']));
|
||||
}
|
||||
}
|
||||
@@ -168,16 +168,12 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection to an Arr instance
|
||||
/// </summary>
|
||||
/// <param name="arrInstance">The instance to test connection to</param>
|
||||
/// <returns>Task that completes when the connection test is done</returns>
|
||||
public virtual async Task TestConnectionAsync(ArrInstance arrInstance)
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task HealthCheckAsync(ArrInstance arrInstance)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/system/status";
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}{GetSystemStatusUrlPath()}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
@@ -188,6 +184,8 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
_logger.LogDebug("Connection test successful for {url}", arrInstance.Url);
|
||||
}
|
||||
|
||||
protected abstract string GetSystemStatusUrlPath();
|
||||
|
||||
protected abstract string GetQueueUrlPath();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr;
|
||||
|
||||
public sealed class ArrClientFactory
|
||||
public sealed class ArrClientFactory : IArrClientFactory
|
||||
{
|
||||
private readonly ISonarrClient _sonarrClient;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
@@ -12,11 +12,11 @@ public sealed class ArrClientFactory
|
||||
private readonly IWhisparrClient _whisparrClient;
|
||||
|
||||
public ArrClientFactory(
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
LidarrClient lidarrClient,
|
||||
ReadarrClient readarrClient,
|
||||
WhisparrClient whisparrClient
|
||||
ISonarrClient sonarrClient,
|
||||
IRadarrClient radarrClient,
|
||||
ILidarrClient lidarrClient,
|
||||
IReadarrClient readarrClient,
|
||||
IWhisparrClient whisparrClient
|
||||
)
|
||||
{
|
||||
_sonarrClient = sonarrClient;
|
||||
|
||||
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr;
|
||||
|
||||
public sealed class ArrQueueIterator
|
||||
public sealed class ArrQueueIterator : IArrQueueIterator
|
||||
{
|
||||
private readonly ILogger<ArrQueueIterator> _logger;
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ public interface IArrClient
|
||||
Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
|
||||
|
||||
bool IsRecordValid(QueueRecord record);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection to an Arr instance
|
||||
/// </summary>
|
||||
/// <param name="arrInstance">The instance to test connection to</param>
|
||||
/// <returns>Task that completes when the connection test is done</returns>
|
||||
Task TestConnectionAsync(ArrInstance arrInstance);
|
||||
Task HealthCheckAsync(ArrInstance arrInstance);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IArrClientFactory
|
||||
{
|
||||
IArrClient GetClient(InstanceType type);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IArrQueueIterator
|
||||
{
|
||||
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user