mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-01-08 05:47:49 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0c881944 | ||
|
|
d0ef01d79b | ||
|
|
9457236e99 | ||
|
|
d43b4fc1c4 | ||
|
|
e9750429eb | ||
|
|
b71b268b08 | ||
|
|
a708d22b27 | ||
|
|
a9a3b08ad6 | ||
|
|
1d1e8679e4 | ||
|
|
142d445ed0 | ||
|
|
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
|
||||
|
||||
26
.github/workflows/build-executable.yml
vendored
26
.github/workflows/build-executable.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
@@ -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
|
||||
|
||||
6
.github/workflows/build-macos-installer.yml
vendored
6
.github/workflows/build-macos-installer.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
@@ -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"
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
|
||||
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:
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
|
||||
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- [Node.js 18+](https://nodejs.org/)
|
||||
- [Git](https://git-scm.com/)
|
||||
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
|
||||
|
||||
11
README.md
11
README.md
@@ -28,20 +28,23 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||
|
||||
## Sponsored by GitAds
|
||||
[](https://gitads.dev/v1/ad-track?source=cleanuparr/cleanuparr@github)
|
||||
|
||||
## Screenshots
|
||||
|
||||
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
|
||||
## 🎯 Supported Applications
|
||||
|
||||
### *Arr Applications
|
||||
### *Arr Applications (latest version)
|
||||
- **Sonarr**
|
||||
- **Radarr**
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr**
|
||||
- **Whisparr v2**
|
||||
|
||||
### Download Clients
|
||||
### Download Clients (latest version)
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
@@ -116,4 +119,4 @@ Special thanks for inspiration go to:
|
||||
# Buy me a coffee
|
||||
If I made your life just a tiny bit easier, consider buying me a coffee!
|
||||
|
||||
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
@@ -38,4 +38,50 @@ backend/**/Tests/
|
||||
|
||||
# Development files
|
||||
docker-compose*.yml
|
||||
test/
|
||||
test/
|
||||
|
||||
# ================================
|
||||
# Node and build output
|
||||
# ================================
|
||||
node_modules
|
||||
dist
|
||||
out-tsc
|
||||
.angular
|
||||
.cache
|
||||
.tmp
|
||||
|
||||
# ================================
|
||||
# Testing & Coverage
|
||||
# ================================
|
||||
coverage
|
||||
jest
|
||||
cypress
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
reports
|
||||
playwright-report
|
||||
.vite
|
||||
.vitepress
|
||||
|
||||
# ================================
|
||||
# Environment & log files
|
||||
# ================================
|
||||
*.env*
|
||||
!*.env.production
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# ================================
|
||||
# Docker & local orchestration
|
||||
# ================================
|
||||
Dockerfile
|
||||
Dockerfile.*
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
docker-compose*.yml
|
||||
|
||||
# ================================
|
||||
# Miscellaneous
|
||||
# ================================
|
||||
*.bak
|
||||
*.old
|
||||
@@ -15,7 +15,7 @@ COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Build .NET backend
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=0.0.1
|
||||
ARG PACKAGES_USERNAME
|
||||
@@ -23,10 +23,6 @@ ARG PACKAGES_PAT
|
||||
WORKDIR /app
|
||||
EXPOSE 11011
|
||||
|
||||
# Copy solution and project files first for better layer caching
|
||||
# COPY backend/*.sln ./backend/
|
||||
# COPY backend/*/*.csproj ./backend/*/
|
||||
|
||||
# Copy source code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
@@ -46,15 +42,23 @@ RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
# Runtime stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install required packages for user management and timezone support
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Install required packages for user management, timezone support, and Python for Apprise CLI
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tzdata \
|
||||
gosu \
|
||||
python3 \
|
||||
python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create virtual environment and install Apprise CLI
|
||||
ENV VIRTUAL_ENV=/opt/apprise-venv
|
||||
RUN python3 -m venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
RUN pip install --no-cache-dir apprise==1.9.6
|
||||
|
||||
ENV PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
|
||||
@@ -13,4 +13,24 @@ migrate-events:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-events name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
|
||||
docker-build:
|
||||
ifndef tag
|
||||
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef version
|
||||
$(error version is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef user
|
||||
$(error user is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef pat
|
||||
$(error pat is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg VERSION=$(version) \
|
||||
--build-arg PACKAGES_USERNAME=$(user) \
|
||||
--build-arg PACKAGES_PAT=$(pat) \
|
||||
-t cleanuparr:$(tag) \
|
||||
.
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Cleanuparr</AssemblyName>
|
||||
<Version Condition="'$(Version)' == ''">0.0.1</Version>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
@@ -24,14 +24,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.1" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -14,18 +13,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;
|
||||
}
|
||||
|
||||
@@ -178,8 +174,8 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
|
||||
await sonarrClient.TestConnectionAsync(instance);
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
|
||||
await sonarrClient.HealthCheckAsync(instance);
|
||||
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
@@ -210,8 +206,8 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
|
||||
await radarrClient.TestConnectionAsync(instance);
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
|
||||
await radarrClient.HealthCheckAsync(instance);
|
||||
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
@@ -242,8 +238,8 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
|
||||
await lidarrClient.TestConnectionAsync(instance);
|
||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
|
||||
await lidarrClient.HealthCheckAsync(instance);
|
||||
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
|
||||
@@ -2,6 +2,8 @@ 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;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -11,7 +13,11 @@ public static class NotificationsDI
|
||||
services
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<IAppriseCliProxy, AppriseCliProxy>()
|
||||
.AddSingleton<IAppriseCliDetector, AppriseCliDetector>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||
.AddScoped<ITelegramProxy, TelegramProxy>()
|
||||
.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,19 @@ 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<IWhisparrV2Client, WhisparrV2Client>()
|
||||
.AddScoped<IWhisparrV3Client, WhisparrV3Client>()
|
||||
.AddScoped<IArrClientFactory, ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
@@ -45,17 +45,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>();
|
||||
}
|
||||
@@ -18,6 +18,9 @@ public sealed record ArrInstanceRequest
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
[Required]
|
||||
public required float Version { get; init; }
|
||||
|
||||
public ArrInstance ToEntity(Guid configId) => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
@@ -25,6 +28,7 @@ public sealed record ArrInstanceRequest
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = configId,
|
||||
Version = Version,
|
||||
};
|
||||
|
||||
public void ApplyTo(ArrInstance instance)
|
||||
@@ -33,5 +37,6 @@ public sealed record ArrInstanceRequest
|
||||
instance.Name = Name;
|
||||
instance.Url = new Uri(Url);
|
||||
instance.ApiKey = ApiKey;
|
||||
instance.Version = Version;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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; }
|
||||
|
||||
[Required]
|
||||
public required float Version { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
Version = Version,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
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;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Controllers;
|
||||
|
||||
@@ -20,13 +15,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 +122,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 +278,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, request.Version);
|
||||
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}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public AppriseMode Mode { get; init; } = AppriseMode.Api;
|
||||
|
||||
// API mode fields
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
|
||||
// CLI mode fields
|
||||
public string? ServiceUrls { get; init; }
|
||||
}
|
||||
|
||||
@@ -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,12 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public sealed record CreateTelegramProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string BotToken { get; init; } = string.Empty;
|
||||
|
||||
public string ChatId { get; init; } = string.Empty;
|
||||
|
||||
public string? TopicId { get; init; }
|
||||
|
||||
public bool SendSilently { get; init; }
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestAppriseProviderRequest
|
||||
{
|
||||
public AppriseMode Mode { get; init; } = AppriseMode.Api;
|
||||
|
||||
// API mode fields
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
|
||||
// CLI mode fields
|
||||
public string? ServiceUrls { 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,12 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public sealed record TestTelegramProviderRequest
|
||||
{
|
||||
public string BotToken { get; init; } = string.Empty;
|
||||
|
||||
public string ChatId { get; init; } = string.Empty;
|
||||
|
||||
public string? TopicId { get; init; }
|
||||
|
||||
public bool SendSilently { get; init; }
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public AppriseMode Mode { get; init; } = AppriseMode.Api;
|
||||
|
||||
// API mode fields
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
|
||||
// CLI mode fields
|
||||
public string? ServiceUrls { 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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateTelegramProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string BotToken { get; init; } = string.Empty;
|
||||
|
||||
public string ChatId { get; init; } = string.Empty;
|
||||
|
||||
public string? TopicId { get; init; }
|
||||
|
||||
public bool SendSilently { get; init; }
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Controllers;
|
||||
|
||||
@@ -21,17 +22,20 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly INotificationConfigurationService _notificationConfigurationService;
|
||||
private readonly NotificationService _notificationService;
|
||||
private readonly IAppriseCliDetector _appriseCliDetector;
|
||||
|
||||
public NotificationProvidersController(
|
||||
ILogger<NotificationProvidersController> logger,
|
||||
DataContext dataContext,
|
||||
INotificationConfigurationService notificationConfigurationService,
|
||||
NotificationService notificationService)
|
||||
NotificationService notificationService,
|
||||
IAppriseCliDetector appriseCliDetector)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_notificationConfigurationService = notificationConfigurationService;
|
||||
_notificationService = notificationService;
|
||||
_appriseCliDetector = appriseCliDetector;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -44,6 +48,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -68,6 +74,8 @@ 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(),
|
||||
NotificationProviderType.Telegram => p.TelegramConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
})
|
||||
@@ -84,6 +92,18 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("apprise/cli-status")]
|
||||
public async Task<IActionResult> GetAppriseCliStatus()
|
||||
{
|
||||
string? version = await _appriseCliDetector.GetAppriseVersionAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Available = version is not null,
|
||||
Version = version
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("notifiarr")]
|
||||
public async Task<IActionResult> CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderRequest newProvider)
|
||||
{
|
||||
@@ -160,9 +180,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Mode = newProvider.Mode,
|
||||
Url = newProvider.Url,
|
||||
Key = newProvider.Key,
|
||||
Tags = newProvider.Tags
|
||||
Tags = newProvider.Tags,
|
||||
ServiceUrls = newProvider.ServiceUrls
|
||||
};
|
||||
appriseConfig.Validate();
|
||||
|
||||
@@ -270,6 +292,69 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("telegram")]
|
||||
public async Task<IActionResult> CreateTelegramProvider([FromBody] CreateTelegramProviderRequest 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 telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = newProvider.BotToken,
|
||||
ChatId = newProvider.ChatId,
|
||||
TopicId = newProvider.TopicId,
|
||||
SendSilently = newProvider.SendSilently
|
||||
};
|
||||
telegramConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Telegram,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
TelegramConfiguration = telegramConfig
|
||||
};
|
||||
|
||||
_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 Telegram provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("notifiarr/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderRequest updatedProvider)
|
||||
{
|
||||
@@ -380,9 +465,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Mode = updatedProvider.Mode,
|
||||
Url = updatedProvider.Url,
|
||||
Key = updatedProvider.Key,
|
||||
Tags = updatedProvider.Tags
|
||||
Tags = updatedProvider.Tags,
|
||||
ServiceUrls = updatedProvider.ServiceUrls
|
||||
};
|
||||
|
||||
if (existingProvider.AppriseConfiguration != null)
|
||||
@@ -514,6 +601,87 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("telegram/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateTelegramProvider(Guid id, [FromBody] UpdateTelegramProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Telegram);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Telegram 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 telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = updatedProvider.BotToken,
|
||||
ChatId = updatedProvider.ChatId,
|
||||
TopicId = updatedProvider.TopicId,
|
||||
SendSilently = updatedProvider.SendSilently
|
||||
};
|
||||
|
||||
if (existingProvider.TelegramConfiguration != null)
|
||||
{
|
||||
telegramConfig = telegramConfig with { Id = existingProvider.TelegramConfiguration.Id };
|
||||
}
|
||||
telegramConfig.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,
|
||||
TelegramConfiguration = telegramConfig,
|
||||
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 Telegram provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
|
||||
{
|
||||
@@ -524,6 +692,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
@@ -583,12 +753,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}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,9 +769,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Mode = testRequest.Mode,
|
||||
Url = testRequest.Url,
|
||||
Key = testRequest.Key,
|
||||
Tags = testRequest.Tags
|
||||
Tags = testRequest.Tags,
|
||||
ServiceUrls = testRequest.ServiceUrls
|
||||
};
|
||||
appriseConfig.Validate();
|
||||
|
||||
@@ -624,12 +796,16 @@ 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 (AppriseException exception)
|
||||
{
|
||||
return StatusCode((int)HttpStatusCode.InternalServerError, exception.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Apprise provider");
|
||||
throw;
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,12 +846,59 @@ 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}" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("telegram/test")]
|
||||
public async Task<IActionResult> TestTelegramProvider([FromBody] TestTelegramProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = testRequest.BotToken,
|
||||
ChatId = testRequest.ChatId,
|
||||
TopicId = testRequest.TopicId,
|
||||
SendSilently = testRequest.SendSilently
|
||||
};
|
||||
telegramConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Telegram,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = telegramConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (TelegramException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to test Telegram provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Telegram provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,8 +924,208 @@ 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(),
|
||||
NotificationProviderType.Telegram => provider.TelegramConfiguration ?? 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;
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api;
|
||||
@@ -33,12 +34,24 @@ builder.Configuration
|
||||
int.TryParse(builder.Configuration.GetValue<string>("PORT"), out int port);
|
||||
port = port is 0 ? 11011 : port;
|
||||
|
||||
string? bindAddress = builder.Configuration.GetValue<string>("BIND_ADDRESS");
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
// If no port is configured, default to 11011
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ListenAnyIP(port);
|
||||
if (string.IsNullOrEmpty(bindAddress) || bindAddress is "0.0.0.0" || bindAddress is "*")
|
||||
{
|
||||
options.ListenAnyIP(port);
|
||||
}
|
||||
else if (IPAddress.TryParse(bindAddress, out var ipAddress))
|
||||
{
|
||||
options.Listen(ipAddress, port);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException($"Invalid BIND_ADDRESS: '{bindAddress}'");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +137,7 @@ if (basePath is not null)
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
|
||||
logger.LogInformation("Server configuration: BIND_ADDRESS={bindAddress}, PORT={port}, BASE_PATH={basePath}", bindAddress ?? "0.0.0.0", port, basePath ?? "/");
|
||||
|
||||
// Initialize the host
|
||||
app.Init();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -2,17 +2,17 @@ namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public sealed record QueueRecord
|
||||
{
|
||||
// Sonarr and Whisparr
|
||||
// Sonarr and Whisparr v2
|
||||
public long SeriesId { get; init; }
|
||||
public long EpisodeId { get; init; }
|
||||
public long SeasonNumber { get; init; }
|
||||
|
||||
public QueueSeries? Series { get; init; }
|
||||
|
||||
// Radarr
|
||||
// Radarr and Whisparr v3
|
||||
public long MovieId { get; init; }
|
||||
|
||||
public QueueSeries? Movie { get; init; }
|
||||
public QueueMovie? Movie { get; init; }
|
||||
|
||||
// Lidarr
|
||||
public long ArtistId { get; init; }
|
||||
|
||||
@@ -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.
|
||||
@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Whisparr;
|
||||
|
||||
public sealed record WhisparrCommand
|
||||
public sealed record WhisparrV2Command
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Whisparr;
|
||||
|
||||
public sealed record WhisparrV3Command
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required List<long> MovieIds { get; init; }
|
||||
}
|
||||
7
code/backend/Cleanuparr.Domain/Enums/AppriseMode.cs
Normal file
7
code/backend/Cleanuparr.Domain/Enums/AppriseMode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum AppriseMode
|
||||
{
|
||||
Api,
|
||||
Cli
|
||||
}
|
||||
@@ -4,5 +4,7 @@ public enum NotificationProviderType
|
||||
{
|
||||
Notifiarr,
|
||||
Apprise,
|
||||
Ntfy
|
||||
Ntfy,
|
||||
Pushover,
|
||||
Telegram,
|
||||
}
|
||||
|
||||
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,11 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
@@ -15,8 +19,9 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.1" />
|
||||
<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.1.5">
|
||||
<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,147 @@
|
||||
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<IWhisparrV2Client> _whisparrClientMock;
|
||||
private readonly Mock<IWhisparrV3Client> _whisparrV3ClientMock;
|
||||
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<IWhisparrV2Client>();
|
||||
_whisparrV3ClientMock = new Mock<IWhisparrV3Client>();
|
||||
|
||||
_factory = new ArrClientFactory(
|
||||
_sonarrClientMock.Object,
|
||||
_radarrClientMock.Object,
|
||||
_lidarrClientMock.Object,
|
||||
_readarrClientMock.Object,
|
||||
_whisparrClientMock.Object,
|
||||
_whisparrV3ClientMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region GetClient Tests
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Sonarr_ReturnsSonarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Sonarr, 0);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_sonarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Radarr_ReturnsRadarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Radarr, 0);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_radarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Lidarr_ReturnsLidarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Lidarr, 0);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_lidarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Readarr_ReturnsReadarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Readarr, 0);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_readarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Whisparr_ReturnsWhisparrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Whisparr, 2);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_whisparrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_WhisparrV3_ReturnsWhisparrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Whisparr, 3);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_whisparrV3ClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_UnsupportedType_ThrowsNotImplementedException()
|
||||
{
|
||||
// Arrange
|
||||
var unsupportedType = (InstanceType)999;
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotImplementedException>(() => _factory.GetClient(unsupportedType, It.IsAny<float>()));
|
||||
Assert.Contains("not yet supported", exception.Message);
|
||||
Assert.Contains("999", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InstancesData))]
|
||||
public void GetClient_AllSupportedTypes_ReturnsNonNullClient(InstanceType instanceType, float? version)
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(instanceType, version ?? 0f);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsAssignableFrom<IArrClient>(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InstancesData))]
|
||||
public void GetClient_CalledMultipleTimes_ReturnsSameInstance(InstanceType instanceType, float? version)
|
||||
{
|
||||
// Act
|
||||
var result1 = _factory.GetClient(instanceType, version ?? 0f);
|
||||
var result2 = _factory.GetClient(instanceType, version ?? 0f);
|
||||
|
||||
// Assert
|
||||
Assert.Same(result1, result2);
|
||||
}
|
||||
|
||||
public static IEnumerable<object?[]> InstancesData =>
|
||||
[
|
||||
[InstanceType.Sonarr, null],
|
||||
[InstanceType.Radarr, null],
|
||||
[InstanceType.Lidarr, null],
|
||||
[InstanceType.Readarr, null],
|
||||
[InstanceType.Whisparr, 2f],
|
||||
[InstanceType.Whisparr, 3f]
|
||||
];
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
|
||||
|
||||
public class WhisparrV2ClientTests
|
||||
{
|
||||
private readonly Mock<ILogger<WhisparrV2Client>> _loggerMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<IStriker> _strikerMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
private readonly WhisparrV2Client _client;
|
||||
|
||||
public WhisparrV2ClientTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<WhisparrV2Client>>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
_strikerMock = new Mock<IStriker>();
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
_client = new WhisparrV2Client(
|
||||
_loggerMock.Object,
|
||||
_httpClientFactoryMock.Object,
|
||||
_strikerMock.Object,
|
||||
_dryRunInterceptorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region IsRecordValid Tests
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenEpisodeIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 0,
|
||||
SeriesId = 1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("episode id and/or series id missing")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenSeriesIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 1,
|
||||
SeriesId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenBothIdsAreZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 0,
|
||||
SeriesId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenBothIdsAreSet_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = null!,
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
|
||||
|
||||
public class WhisparrV3ClientTests
|
||||
{
|
||||
private readonly Mock<ILogger<WhisparrV3Client>> _loggerMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<IStriker> _strikerMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
private readonly WhisparrV3Client _client;
|
||||
|
||||
public WhisparrV3ClientTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<WhisparrV3Client>>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
_strikerMock = new Mock<IStriker>();
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
_client = new WhisparrV3Client(
|
||||
_loggerMock.Object,
|
||||
_httpClientFactoryMock.Object,
|
||||
_strikerMock.Object,
|
||||
_dryRunInterceptorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region IsRecordValid Tests
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenMovieIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
MovieId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("movie id missing")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenMovieIdIsSet_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = null!,
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "",
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#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,750 @@
|
||||
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 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,866 @@
|
||||
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 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,703 @@
|
||||
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 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,312 @@
|
||||
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>(), It.IsAny<float>()))
|
||||
.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>(), It.IsAny<float>()), 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, It.IsAny<float>()), 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, It.IsAny<float>()), 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",
|
||||
Version = 0
|
||||
};
|
||||
}
|
||||
|
||||
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>(), It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()), 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>(), It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()), 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>(), It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()), Times.Once);
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), 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, It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()))
|
||||
.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, It.IsAny<float>()))
|
||||
.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,134 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.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 Mock<IHardLinkFileService> HardLinkFileService { 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>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
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();
|
||||
HardLinkFileService.Reset();
|
||||
Cache.Clear();
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
|
||||
SetupDefaultBehaviors();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DataContext?.Dispose();
|
||||
Cache?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
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, float version = 2)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Whisparr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Test Whisparr v{version}",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
Version = version,
|
||||
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,34 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise;
|
||||
|
||||
public class AppriseCliDetectorTests
|
||||
{
|
||||
private readonly AppriseCliDetector _detector;
|
||||
|
||||
public AppriseCliDetectorTests()
|
||||
{
|
||||
_detector = new AppriseCliDetector(Substitute.For<ILogger<AppriseCliDetector>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var detector = new AppriseCliDetector(Substitute.For<ILogger<AppriseCliDetector>>());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(detector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAppriseVersionAsync_DoesNotThrow()
|
||||
{
|
||||
// Act & Assert - should handle missing CLI gracefully without throwing
|
||||
var exception = await Record.ExceptionAsync(() => _detector.GetAppriseVersionAsync());
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise;
|
||||
|
||||
public class AppriseCliProxyTests
|
||||
{
|
||||
private readonly AppriseCliProxy _proxy;
|
||||
|
||||
public AppriseCliProxyTests()
|
||||
{
|
||||
_proxy = new AppriseCliProxy();
|
||||
}
|
||||
|
||||
private static ApprisePayload CreatePayload(string title = "Test Title", string body = "Test Body")
|
||||
{
|
||||
return new ApprisePayload
|
||||
{
|
||||
Title = title,
|
||||
Body = body,
|
||||
Type = "info"
|
||||
};
|
||||
}
|
||||
|
||||
private static AppriseConfig CreateConfig(string? serviceUrls = null)
|
||||
{
|
||||
return new AppriseConfig
|
||||
{
|
||||
ServiceUrls = serviceUrls
|
||||
};
|
||||
}
|
||||
|
||||
#region SendNotification Validation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenServiceUrlsIsNull_ThrowsAppriseException()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateConfig(null);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
_proxy.SendNotification(CreatePayload(), config));
|
||||
Assert.Contains("No service URLs configured", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenServiceUrlsIsEmpty_ThrowsAppriseException()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateConfig("");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
_proxy.SendNotification(CreatePayload(), config));
|
||||
Assert.Contains("No service URLs configured", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenServiceUrlsIsWhitespace_ThrowsAppriseException()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateConfig(" \n \n ");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
_proxy.SendNotification(CreatePayload(), config));
|
||||
Assert.Contains("No service URLs configured", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -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,241 @@
|
||||
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> _apiProxyMock;
|
||||
private readonly Mock<IAppriseCliProxy> _cliProxyMock;
|
||||
private readonly AppriseConfig _config;
|
||||
private readonly AppriseProvider _provider;
|
||||
|
||||
public AppriseProviderTests()
|
||||
{
|
||||
_apiProxyMock = new Mock<IAppriseProxy>();
|
||||
_cliProxyMock = new Mock<IAppriseCliProxy>();
|
||||
_config = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "http://apprise.example.com",
|
||||
Key = "testkey",
|
||||
Tags = "tag1,tag2"
|
||||
};
|
||||
|
||||
_provider = new AppriseProvider(
|
||||
"TestApprise",
|
||||
NotificationProviderType.Apprise,
|
||||
_config,
|
||||
_apiProxyMock.Object,
|
||||
_cliProxyMock.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;
|
||||
|
||||
_apiProxyMock.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;
|
||||
|
||||
_apiProxyMock.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;
|
||||
|
||||
_apiProxyMock.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;
|
||||
|
||||
_apiProxyMock.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();
|
||||
|
||||
_apiProxyMock.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;
|
||||
|
||||
_apiProxyMock.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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CliMode_CallsCliProxy()
|
||||
{
|
||||
// Arrange
|
||||
var cliConfig = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mode = AppriseMode.Cli,
|
||||
ServiceUrls = "discord://webhook_id/token"
|
||||
};
|
||||
|
||||
var apiProxyMock = new Mock<IAppriseProxy>();
|
||||
var cliProxyMock = new Mock<IAppriseCliProxy>();
|
||||
|
||||
var provider = new AppriseProvider(
|
||||
"TestAppriseCli",
|
||||
NotificationProviderType.Apprise,
|
||||
cliConfig,
|
||||
apiProxyMock.Object,
|
||||
cliProxyMock.Object);
|
||||
|
||||
var context = CreateTestContext();
|
||||
|
||||
cliProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), cliConfig))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
cliProxyMock.Verify(p => p.SendNotification(It.IsAny<ApprisePayload>(), cliConfig), Times.Once);
|
||||
apiProxyMock.Verify(p => p.SendNotification(It.IsAny<ApprisePayload>(), It.IsAny<AppriseConfig>()), Times.Never);
|
||||
}
|
||||
|
||||
#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,553 @@
|
||||
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)]
|
||||
[InlineData(NotificationProviderType.Telegram)]
|
||||
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)]
|
||||
[InlineData(NotificationProviderType.Telegram)]
|
||||
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),
|
||||
NotificationProviderType.Telegram => CreateTelegramConfig(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"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateTelegramConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Telegram,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
TelegramConfiguration = new TelegramConfig()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BotToken = "test_bot_token_1234567890abcd",
|
||||
ChatId = "1234567890",
|
||||
TopicId = "-1234567890",
|
||||
SendSilently = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#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,331 @@
|
||||
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.Infrastructure.Features.Notifications.Telegram;
|
||||
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<IAppriseCliProxy> _appriseCliProxyMock;
|
||||
private readonly Mock<INtfyProxy> _ntfyProxyMock;
|
||||
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
|
||||
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
|
||||
private readonly Mock<ITelegramProxy> _telegramProxyMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NotificationProviderFactory _factory;
|
||||
|
||||
public NotificationProviderFactoryTests()
|
||||
{
|
||||
_appriseProxyMock = new Mock<IAppriseProxy>();
|
||||
_appriseCliProxyMock = new Mock<IAppriseCliProxy>();
|
||||
_ntfyProxyMock = new Mock<INtfyProxy>();
|
||||
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
|
||||
_pushoverProxyMock = new Mock<IPushoverProxy>();
|
||||
_telegramProxyMock = new Mock<ITelegramProxy>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_appriseProxyMock.Object);
|
||||
services.AddSingleton(_appriseCliProxyMock.Object);
|
||||
services.AddSingleton(_ntfyProxyMock.Object);
|
||||
services.AddSingleton(_notifiarrProxyMock.Object);
|
||||
services.AddSingleton(_pushoverProxyMock.Object);
|
||||
services.AddSingleton(_telegramProxyMock.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_TelegramType_CreatesTelegramProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestTelegram",
|
||||
Type = NotificationProviderType.Telegram,
|
||||
IsEnabled = true,
|
||||
Configuration = new TelegramConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BotToken = "test-bot-token",
|
||||
ChatId = "123456789",
|
||||
SendSilently = false
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<TelegramProvider>(provider);
|
||||
Assert.Equal("TestTelegram", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Telegram, 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>() }),
|
||||
(Type: NotificationProviderType.Telegram, Config: (object)new TelegramConfig { Id = Guid.NewGuid(), BotToken = "token", ChatId = "123456789", SendSilently = false })
|
||||
};
|
||||
|
||||
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,598 @@
|
||||
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), instanceType);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://sonarr.local"));
|
||||
ContextProvider.Set("version", 1f);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Telegram;
|
||||
|
||||
public class TelegramProviderTests
|
||||
{
|
||||
private readonly Mock<ITelegramProxy> _proxyMock;
|
||||
private readonly TelegramConfig _config;
|
||||
private readonly TelegramProvider _provider;
|
||||
|
||||
public TelegramProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<ITelegramProxy>();
|
||||
_config = new TelegramConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BotToken = "test-bot-token",
|
||||
ChatId = "123456789",
|
||||
TopicId = null,
|
||||
SendSilently = false
|
||||
};
|
||||
|
||||
_provider = new TelegramProvider(
|
||||
"TestTelegram",
|
||||
NotificationProviderType.Telegram,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestTelegram", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Telegram, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectBotToken()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
string? capturedBotToken = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((_, token) => capturedBotToken = token)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-bot-token", capturedBotToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectChatId()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("123456789", capturedPayload.ChatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TrimsChatId()
|
||||
{
|
||||
// Arrange
|
||||
var config = new TelegramConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BotToken = "token",
|
||||
ChatId = " 123456789 ",
|
||||
SendSilently = false
|
||||
};
|
||||
|
||||
var provider = new TelegramProvider("Test", NotificationProviderType.Telegram, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("123456789", capturedPayload.ChatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesTitleInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("Test Notification", capturedPayload.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDescriptionInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("Test Description", capturedPayload.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("TestKey: TestValue", capturedPayload.Text);
|
||||
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_HtmlEncodesSpecialCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test <script>alert('xss')</script>",
|
||||
Description = "Description with & and < and >",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("<script>", capturedPayload.Text);
|
||||
Assert.Contains("&", capturedPayload.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithTopicId_SetsMessageThreadId()
|
||||
{
|
||||
// Arrange
|
||||
var config = new TelegramConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BotToken = "token",
|
||||
ChatId = "123456789",
|
||||
TopicId = "42",
|
||||
SendSilently = false
|
||||
};
|
||||
|
||||
var provider = new TelegramProvider("Test", NotificationProviderType.Telegram, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(42, capturedPayload.MessageThreadId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("invalid")]
|
||||
public async Task SendNotificationAsync_WithInvalidTopicId_SetsMessageThreadIdToNull(string? topicId)
|
||||
{
|
||||
// Arrange
|
||||
var config = new TelegramConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BotToken = "token",
|
||||
ChatId = "123456789",
|
||||
TopicId = topicId,
|
||||
SendSilently = false
|
||||
};
|
||||
|
||||
var provider = new TelegramProvider("Test", NotificationProviderType.Telegram, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Null(capturedPayload.MessageThreadId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithSendSilently_SetsDisableNotification()
|
||||
{
|
||||
// Arrange
|
||||
var config = new TelegramConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BotToken = "token",
|
||||
ChatId = "123456789",
|
||||
SendSilently = true
|
||||
};
|
||||
|
||||
var provider = new TelegramProvider("Test", NotificationProviderType.Telegram, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.True(capturedPayload.DisableNotification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithImage_SetsPhotoUrl()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>(),
|
||||
Image = new Uri("https://example.com/image.jpg")
|
||||
};
|
||||
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("https://example.com/image.jpg", capturedPayload.PhotoUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithoutImage_PhotoUrlIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Null(capturedPayload.PhotoUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_MessageContainsOnlyTitleAndDescription()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description Only",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("Test Title", capturedPayload.Text);
|
||||
Assert.Contains("Test Description Only", capturedPayload.Text);
|
||||
Assert.DoesNotContain(":", capturedPayload.Text.Replace("Test Title", "").Replace("Test Description Only", "").Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TrimsWhitespaceFromTitleAndDescription()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = " Trimmed Title ",
|
||||
Description = " Trimmed Description ",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.DoesNotContain(" Trimmed", capturedPayload.Text);
|
||||
Assert.Contains("Trimmed Title", capturedPayload.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new TelegramException("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TelegramException>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyTitle_DoesNotIncludeTitleInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = " ",
|
||||
Description = "Description without title",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
TelegramPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
|
||||
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("Description without title", capturedPayload.Text);
|
||||
}
|
||||
|
||||
#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,456 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Telegram;
|
||||
|
||||
public class TelegramProxyTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public TelegramProxyTests()
|
||||
{
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private TelegramProxy CreateProxy()
|
||||
{
|
||||
return new TelegramProxy(_httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static TelegramPayload CreatePayload(string text = "Test message", string? photoUrl = null)
|
||||
{
|
||||
return new TelegramPayload
|
||||
{
|
||||
ChatId = "123456789",
|
||||
Text = text,
|
||||
PhotoUrl = photoUrl,
|
||||
MessageThreadId = null,
|
||||
DisableNotification = false
|
||||
};
|
||||
}
|
||||
|
||||
#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(), "test-bot-token");
|
||||
}
|
||||
|
||||
[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(), "test-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithoutPhoto_UseSendMessageEndpoint()
|
||||
{
|
||||
// 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));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), "my-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("/botmy-bot-token/sendMessage", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithPhotoAndShortCaption_UsesSendPhotoEndpoint()
|
||||
{
|
||||
// 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 payload = CreatePayload(text: "Short caption", photoUrl: "https://example.com/image.jpg");
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload, "my-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("/botmy-bot-token/sendPhoto", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithPhotoAndLongCaption_UsesSendMessageEndpoint()
|
||||
{
|
||||
// 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));
|
||||
|
||||
// Caption longer than 1024 characters
|
||||
var payload = CreatePayload(text: new string('A', 1025), photoUrl: "https://example.com/image.jpg");
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload, "my-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("/botmy-bot-token/sendMessage", 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(), "test-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithPhotoAndLongCaption_IncludesInvisibleImageLink()
|
||||
{
|
||||
// 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(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Caption longer than 1024 characters
|
||||
var payload = CreatePayload(text: new string('A', 1025), photoUrl: "https://example.com/image.jpg");
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload, "test-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("​", capturedContent); // Zero-width space
|
||||
Assert.Contains("example.com/image.jpg", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithoutPhoto_DisablesWebPagePreview()
|
||||
{
|
||||
// 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(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), "test-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("disable_web_page_preview", capturedContent);
|
||||
Assert.Contains("true", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithMessageThreadId_IncludesThreadIdInBody()
|
||||
{
|
||||
// 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(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var payload = new TelegramPayload
|
||||
{
|
||||
ChatId = "123456789",
|
||||
Text = "Test message",
|
||||
MessageThreadId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload, "test-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("message_thread_id", capturedContent);
|
||||
Assert.Contains("42", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithDisableNotification_IncludesDisableNotificationInBody()
|
||||
{
|
||||
// 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(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var payload = new TelegramPayload
|
||||
{
|
||||
ChatId = "123456789",
|
||||
Text = "Test message",
|
||||
DisableNotification = true
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload, "test-bot-token");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("disable_notification", capturedContent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When400_ThrowsTelegramExceptionWithRejectedMessage()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.BadRequest, "Bad Request: chat not found");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), "test-bot-token"));
|
||||
Assert.Contains("rejected the request", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsTelegramExceptionWithInvalidToken()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized, "Unauthorized");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), "test-bot-token"));
|
||||
Assert.Contains("bot token is invalid", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When403_ThrowsTelegramExceptionWithPermissionDenied()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Forbidden, "Forbidden: bot was blocked by the user");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), "test-bot-token"));
|
||||
Assert.Contains("permission", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When429_ThrowsTelegramExceptionWithRateLimitMessage()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse((HttpStatusCode)429, "Too Many Requests");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), "test-bot-token"));
|
||||
Assert.Contains("Rate limited", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherHttpError_ThrowsTelegramExceptionWithStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError, "Internal Server Error");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), "test-bot-token"));
|
||||
Assert.Contains("500", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsTelegramException()
|
||||
{
|
||||
// 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<TelegramException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), "test-bot-token"));
|
||||
Assert.Contains("Unable to reach Telegram API", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenErrorResponseTruncatesLongBody()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
var longErrorBody = new string('X', 600);
|
||||
SetupErrorResponse(HttpStatusCode.BadRequest, longErrorBody);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), "test-bot-token"));
|
||||
Assert.True(ex.Message.Length < 600);
|
||||
}
|
||||
|
||||
#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, string body = "")
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(body)
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user