Compare commits

..

15 Commits
v2.4.5 ... main

Author SHA1 Message Date
Flaminel
375094862c Add test button for arrs and download clients (#391) 2025-12-20 17:06:03 +02:00
Flaminel
58a72cef0f Add option for multiple ignored root directories (#390) 2025-12-20 17:04:36 +02:00
Flaminel
4ceff127a7 Add option to keep source files when cleaning downloads (#388) 2025-12-19 23:52:59 +02:00
Flaminel
c07b811cf8 Fix Transmission torrent fetch (#389) 2025-12-19 23:35:23 +02:00
Flaminel
b16fa70774 Add Pushover notification provider (#385) 2025-12-13 21:24:34 +02:00
Flaminel
b343165644 Fix Download Cleaner making too many requests (#368) 2025-12-10 09:22:51 +02:00
Flaminel
02dff0bb9b Fix manual release workflows (#380) 2025-12-01 23:42:22 +02:00
Flaminel
ac3be75082 Fix workflow dispatch defaulting to dev version (#379) 2025-11-30 22:53:23 +02:00
Flaminel
a1663b865a Improve workflow dispatch (#378) 2025-11-30 22:27:40 +02:00
Flaminel
c97a416d1e Fix windows workflow (#377) 2025-11-30 16:22:21 +02:00
Flaminel
d28ab42303 Fix frontend workflow using assets instead of cache (#376) 2025-11-30 15:48:53 +02:00
Flaminel
fbb2bba3b6 Update packages (#375) 2025-11-30 13:14:29 +02:00
Flaminel
08eda22587 Add test workflow and improve workflow parallelization (#369) 2025-11-25 23:05:28 +02:00
Flaminel
a4045eebd3 Add downloads volume to detailed installation docs (#365) 2025-11-22 22:15:37 +02:00
Flaminel
a57cbccbb4 Improve UI validations (#366) 2025-11-22 22:14:50 +02:00
310 changed files with 40856 additions and 6315 deletions

View File

@@ -0,0 +1,30 @@
name: 'Get Vault Secrets'
description: 'Retrieves secrets from HashiCorp Vault using AppRole authentication'
inputs:
vault_host:
description: 'Vault server URL'
required: true
vault_role_id:
description: 'Vault AppRole Role ID'
required: true
vault_secret_id:
description: 'Vault AppRole Secret ID'
required: true
secrets:
description: 'Secrets to retrieve (multiline string, one per line in format: path | output_name)'
required: true
default: |
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
secrets/data/github packages_pat | PACKAGES_PAT
runs:
using: "composite"
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ inputs.vault_host }}
method: approle
roleId: ${{ inputs.vault_role_id }}
secretId: ${{ inputs.vault_secret_id }}
secrets: ${{ inputs.secrets }}

View File

@@ -1,14 +1,26 @@
name: Build Docker Images
on:
push:
tags:
- "v*.*.*"
pull_request:
paths:
- 'code/**'
workflow_dispatch:
workflow_call:
inputs:
push_docker:
description: 'Push Docker image to registry'
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:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build_app:
@@ -27,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]}"
@@ -115,6 +141,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push docker image
id: docker-build
timeout-minutes: 15
uses: docker/build-push-action@v6
with:
@@ -128,11 +155,12 @@ jobs:
VERSION=${{ env.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
outputs: |
type=image
platforms: |
linux/amd64
linux/arm64
push: true
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
tags: |
${{ env.githubTags }}
${{ env.githubTags }}
# Enable BuildKit cache for faster builds
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,40 +1,55 @@
name: Build Executables
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
jobs:
build:
# Build for each platform in parallel using matrix strategy
build-platform:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
include:
- runtime: win-x64
platform: win-amd64
- runtime: linux-x64
platform: linux-amd64
- runtime: linux-arm64
platform: linux-arm64
- runtime: osx-x64
platform: osx-amd64
- runtime: osx-arm64
platform: osx-arm64
steps:
- name: Gate
if: ${{ !startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch' }}
run: |
echo "This workflow only runs on tag events or manual dispatch. Pipeline finished."
exit 0
- name: Set variables
run: |
repoFullName=${{ github.repository }}
ref=${{ github.ref }}
# Handle both tag events and manual dispatch
if [[ "$ref" =~ ^refs/tags/ ]]; then
# Use input version if provided, otherwise determine from ref
if [[ -n "${{ inputs.app_version }}" ]]; then
appVersion="${{ inputs.app_version }}"
releaseVersion="v$appVersion"
elif [[ "$ref" =~ ^refs/tags/ ]]; then
releaseVersion=${ref##refs/tags/}
appVersion=${releaseVersion#v}
else
# For manual dispatch, use a default version
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
appVersion="0.0.1-dev"
fi
repoFullName=${{ github.repository }}
repositoryName=${repoFullName#*/}
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
echo "githubRepositoryName=${repoFullName#*/}" >> $GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
echo "appVersion=$appVersion" >> $GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
@@ -58,27 +73,28 @@ jobs:
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup Node.js for frontend build
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Install dependencies and restore
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Copy frontend to backend wwwroot
@@ -86,52 +102,25 @@ jobs:
mkdir -p code/backend/${{ env.executableName }}/wwwroot
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
- name: Build win-x64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Build ${{ matrix.platform }}
run: |
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
-c Release \
--runtime ${{ matrix.runtime }} \
--self-contained \
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
/p:PublishSingleFile=true \
/p:Version=${{ env.appVersion }} \
/p:DebugSymbols=false
- name: Build linux-x64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Build linux-arm64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Build osx-x64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Build osx-arm64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Zip win-x64
- name: Zip artifact
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
- name: Zip linux-x64
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/
- name: Zip linux-arm64
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/
- name: Zip osx-x64
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/
- name: Zip osx-arm64
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/
- name: Upload artifacts
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: cleanuparr-executables
path: |
./artifacts/*.zip
name: executable-${{ matrix.platform }}
path: ./artifacts/*.zip
retention-days: 30
# Removed individual release step - handled by main release workflow

46
.github/workflows/build-frontend.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Build Frontend
on:
workflow_call:
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
with:
repository: ${{ github.repository }}
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
retention-days: 1

View File

@@ -1,28 +1,47 @@
name: Build macOS ARM Installer
name: Build macOS Installers
permissions:
contents: write
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
jobs:
build-macos-arm-installer:
name: Build macOS ARM Installer
runs-on: macos-14 # ARM runner for Apple Silicon
build-macos-installer:
name: Build macOS ${{ matrix.arch }} Installer
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: Intel
runner: macos-13
runtime: osx-x64
min_os_version: "10.15"
artifact_suffix: intel
- arch: ARM
runner: macos-14
runtime: osx-arm64
min_os_version: "11.0"
artifact_suffix: arm64
steps:
- name: Set variables
run: |
repoFullName=${{ github.repository }}
ref=${{ github.ref }}
# Handle both tag events and manual dispatch
if [[ "$ref" =~ ^refs/tags/ ]]; then
# Use input version if provided, otherwise determine from ref
if [[ -n "${{ inputs.app_version }}" ]]; then
appVersion="${{ inputs.app_version }}"
releaseVersion="v$appVersion"
elif [[ "$ref" =~ ^refs/tags/ ]]; then
releaseVersion=${ref##refs/tags/}
appVersion=${releaseVersion#v}
else
@@ -30,9 +49,9 @@ jobs:
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
appVersion="0.0.1-dev"
fi
repositoryName=${repoFullName#*/}
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
@@ -58,18 +77,11 @@ jobs:
token: ${{ env.REPO_READONLY_PAT }}
fetch-depth: 0
- name: Setup Node.js for frontend build
uses: actions/setup-node@v4
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -81,16 +93,16 @@ jobs:
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Build macOS ARM executable
- name: Build macOS ${{ matrix.arch }} executable
run: |
# Clean any existing output directory
rm -rf dist
mkdir -p dist/temp
# Build to a temporary location
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
-c Release \
--runtime osx-arm64 \
--runtime ${{ matrix.runtime }} \
--self-contained true \
-o dist/temp \
/p:PublishSingleFile=true \
@@ -103,17 +115,17 @@ jobs:
/p:_CodeSignDuringBuild=false \
/p:PublishTrimmed=false \
/p:TrimMode=link
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/MacOS
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Copy frontend directly to where it belongs in the app bundle
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
# Copy any additional runtime files if they exist
if [ -d "dist/temp" ]; then
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
@@ -124,16 +136,16 @@ jobs:
run: |
# Make sure the executable is actually executable
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Remove any .pdb files that might have been created
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
echo "Checking architecture of built binary:"
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
if command -v lipo >/dev/null 2>&1; then
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
fi
echo "Files in MacOS directory:"
ls -la dist/Cleanuparr.app/Contents/MacOS/
@@ -141,12 +153,12 @@ jobs:
run: |
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
# Convert ICO to ICNS for macOS app bundle
if command -v iconutil >/dev/null 2>&1; then
# Create iconset directory structure
mkdir -p Cleanuparr.iconset
# Use existing PNG files from Logo directory for different sizes
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
@@ -158,14 +170,14 @@ jobs:
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
# Create ICNS file
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
# Clean up iconset directory
rm -rf Cleanuparr.iconset
fi
# Create Launch Daemon plist
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
@@ -196,7 +208,7 @@ jobs:
</dict>
</plist>
EOF
# Create Info.plist with proper configuration
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
@@ -228,7 +240,7 @@ jobs:
<key>NSRequiresAquaSystemAppearance</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<string>${{ matrix.min_os_version }}</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSSupportsAutomaticTermination</key>
@@ -245,7 +257,7 @@ jobs:
</dict>
</plist>
EOF
# Clean up temp directory
rm -rf dist/temp
@@ -255,96 +267,96 @@ jobs:
mkdir -p scripts
cat > scripts/preinstall << 'EOF'
#!/bin/bash
# Stop and unload existing launch daemon if it exists
if launchctl list | grep -q "com.cleanuparr.daemon"; then
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
fi
# Stop any running instances of Cleanuparr
pkill -f "Cleanuparr" || true
sleep 2
# Remove old installation if it exists
if [[ -d "/Applications/Cleanuparr.app" ]]; then
rm -rf "/Applications/Cleanuparr.app"
fi
# Remove old launch daemon plist if it exists
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
fi
exit 0
EOF
chmod +x scripts/preinstall
# Create postinstall script
cat > scripts/postinstall << 'EOF'
#!/bin/bash
# Set proper permissions for the app bundle
chmod -R 755 /Applications/Cleanuparr.app
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Install the launch daemon
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
# Load and start the service
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
launchctl start com.cleanuparr.daemon
# Wait a moment for service to start
sleep 3
# Display as system notification
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
exit 0
EOF
chmod +x scripts/postinstall
# Create uninstall script (optional, for user reference)
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
#!/bin/bash
# Cleanuparr Uninstall Script
# Run this script with sudo to completely remove Cleanuparr
echo "Stopping Cleanuparr service..."
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
echo "Removing service files..."
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
echo "Removing application..."
rm -rf /Applications/Cleanuparr.app
echo "Removing logs..."
rm -f /var/log/cleanuparr.log
rm -f /var/log/cleanuparr.error.log
echo "Cleanuparr has been completely removed."
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
EOF
chmod +x scripts/uninstall_cleanuparr.sh
# 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
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64.pkg"
# 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-arm64-dev.pkg"
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
fi
# Create PKG installer with better metadata
pkgbuild --root dist/ \
--scripts scripts/ \
@@ -353,14 +365,12 @@ jobs:
--install-location /Applications \
--ownership preserve \
${pkg_name}
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
- name: Upload installer as artifact
uses: actions/upload-artifact@v4
with:
name: Cleanuparr-macos-arm64-installer
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
path: '${{ env.pkgName }}'
retention-days: 30
# Removed individual release step - handled by main release workflow

View File

@@ -1,366 +0,0 @@
name: Build macOS Intel Installer
permissions:
contents: write
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
workflow_call:
jobs:
build-macos-intel-installer:
name: Build macOS Intel Installer
runs-on: macos-13 # Intel runner
steps:
- name: Set variables
run: |
repoFullName=${{ github.repository }}
ref=${{ github.ref }}
# Handle both tag events and manual dispatch
if [[ "$ref" =~ ^refs/tags/ ]]; then
releaseVersion=${ref##refs/tags/}
appVersion=${releaseVersion#v}
else
# For manual dispatch, use a default version
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
appVersion="0.0.1-dev"
fi
repositoryName=${repoFullName#*/}
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
echo "appVersion=$appVersion" >> $GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout repository
uses: actions/checkout@v4
with:
repository: ${{ env.githubRepository }}
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
fetch-depth: 0
- name: Setup Node.js for frontend build
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Restore .NET dependencies
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Build macOS Intel executable
run: |
# Clean any existing output directory
rm -rf dist
mkdir -p dist/temp
# Build to a temporary location
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
-c Release \
--runtime osx-x64 \
--self-contained true \
-o dist/temp \
/p:PublishSingleFile=true \
/p:Version=${{ env.appVersion }} \
/p:DebugType=None \
/p:DebugSymbols=false \
/p:UseAppHost=true \
/p:EnableMacOSCodeSign=false \
/p:CodeSignOnCopy=false \
/p:_CodeSignDuringBuild=false \
/p:PublishTrimmed=false \
/p:TrimMode=link
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/MacOS
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Copy frontend directly to where it belongs in the app bundle
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
# Copy any additional runtime files if they exist
if [ -d "dist/temp" ]; then
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
fi
- name: Post-build setup
run: |
# Make sure the executable is actually executable
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Remove any .pdb files that might have been created
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
echo "Checking architecture of built binary:"
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
if command -v lipo >/dev/null 2>&1; then
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
fi
echo "Files in MacOS directory:"
ls -la dist/Cleanuparr.app/Contents/MacOS/
- name: Create macOS app bundle structure
run: |
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
# Convert ICO to ICNS for macOS app bundle
if command -v iconutil >/dev/null 2>&1; then
# Create iconset directory structure
mkdir -p Cleanuparr.iconset
# Use existing PNG files from Logo directory for different sizes
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
cp Logo/32.png Cleanuparr.iconset/icon_32x32.png
cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png
cp Logo/128.png Cleanuparr.iconset/icon_128x128.png
cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png
cp Logo/256.png Cleanuparr.iconset/icon_256x256.png
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
# Create ICNS file
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
# Clean up iconset directory
rm -rf Cleanuparr.iconset
fi
# Create Launch Daemon plist
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cleanuparr.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/cleanuparr.log</string>
<key>StandardErrorPath</key>
<string>/var/log/cleanuparr.error.log</string>
<key>WorkingDirectory</key>
<string>/Applications/Cleanuparr.app/Contents/MacOS</string>
<key>EnvironmentVariables</key>
<dict>
<key>HTTP_PORTS</key>
<string>11011</string>
</dict>
</dict>
</plist>
EOF
# Create Info.plist with proper configuration
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cleanuparr</string>
<key>CFBundleIdentifier</key>
<string>com.Cleanuparr</string>
<key>CFBundleName</key>
<string>Cleanuparr</string>
<key>CFBundleDisplayName</key>
<string>Cleanuparr</string>
<key>CFBundleVersion</key>
<string>${{ env.appVersion }}</string>
<key>CFBundleShortVersionString</key>
<string>${{ env.appVersion }}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>CLNR</string>
<key>CFBundleIconFile</key>
<string>Cleanuparr</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSRequiresAquaSystemAppearance</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>NSSupportsSuddenTermination</key>
<false/>
<key>LSBackgroundOnly</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
EOF
# Clean up temp directory
rm -rf dist/temp
- name: Create PKG installer
run: |
# Create preinstall script to handle existing installations
mkdir -p scripts
cat > scripts/preinstall << 'EOF'
#!/bin/bash
# Stop and unload existing launch daemon if it exists
if launchctl list | grep -q "com.cleanuparr.daemon"; then
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
fi
# Stop any running instances of Cleanuparr
pkill -f "Cleanuparr" || true
sleep 2
# Remove old installation if it exists
if [[ -d "/Applications/Cleanuparr.app" ]]; then
rm -rf "/Applications/Cleanuparr.app"
fi
# Remove old launch daemon plist if it exists
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
fi
exit 0
EOF
chmod +x scripts/preinstall
# Create postinstall script
cat > scripts/postinstall << 'EOF'
#!/bin/bash
# Set proper permissions for the app bundle
chmod -R 755 /Applications/Cleanuparr.app
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Install the launch daemon
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
# Load and start the service
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
launchctl start com.cleanuparr.daemon
# Wait a moment for service to start
sleep 3
# Display as system notification
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
exit 0
EOF
chmod +x scripts/postinstall
# Create uninstall script (optional, for user reference)
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
#!/bin/bash
# Cleanuparr Uninstall Script
# Run this script with sudo to completely remove Cleanuparr
echo "Stopping Cleanuparr service..."
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
echo "Removing service files..."
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
echo "Removing application..."
rm -rf /Applications/Cleanuparr.app
echo "Removing logs..."
rm -f /var/log/cleanuparr.log
rm -f /var/log/cleanuparr.error.log
echo "Cleanuparr has been completely removed."
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
EOF
chmod +x scripts/uninstall_cleanuparr.sh
# 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
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel.pkg"
else
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel-dev.pkg"
fi
# Create PKG installer with better metadata
pkgbuild --root dist/ \
--scripts scripts/ \
--identifier com.Cleanuparr \
--version ${{ env.appVersion }} \
--install-location /Applications \
--ownership preserve \
${pkg_name}
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
- name: Upload installer as artifact
uses: actions/upload-artifact@v4
with:
name: Cleanuparr-macos-intel-installer
path: '${{ env.pkgName }}'
retention-days: 30
# Removed individual release step - handled by main release workflow

View File

@@ -1,11 +1,13 @@
name: Build Windows Installer
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
jobs:
build-windows-installer:
@@ -17,9 +19,13 @@ jobs:
run: |
$repoFullName = "${{ github.repository }}"
$ref = "${{ github.ref }}"
# Handle both tag events and manual dispatch
if ($ref -match "^refs/tags/") {
$inputVersion = "${{ inputs.app_version }}"
# Use input version if provided, otherwise determine from ref
if ($inputVersion -ne "") {
$appVersion = $inputVersion
$releaseVersion = "v$appVersion"
} elseif ($ref -match "^refs/tags/") {
$releaseVersion = $ref -replace "refs/tags/", ""
$appVersion = $releaseVersion -replace "^v", ""
} else {
@@ -27,15 +33,15 @@ jobs:
$releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
$appVersion = "0.0.1-dev"
}
$repositoryName = $repoFullName.Split("/")[1]
echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
@@ -55,18 +61,11 @@ jobs:
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup Node.js for frontend build
uses: actions/setup-node@v4
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v4

45
.github/workflows/dependency-review.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Dependency Review
on:
pull_request:
branches:
- main
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
# Fail on critical and high severity vulnerabilities
fail-on-severity: high
# Warn on moderate vulnerabilities
warn-on-severity: moderate
# Allow licenses
# allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD
# Comment summarizes the vulnerabilities found
comment-summary-in-pr: on-failure
# Show dependency changes in PR
show-openssf-scorecard: true
vulnerability-check: true
- name: Upload dependency review results
uses: actions/upload-artifact@v4
with:
name: dependency-review-results
path: dependency-review-*.json
if-no-files-found: ignore
retention-days: 30

View File

@@ -27,7 +27,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 24.x
cache: yarn
cache-dependency-path: docs/yarn.lock

View File

@@ -8,8 +8,32 @@ on:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
required: true
runTests:
description: 'Run test suite'
type: boolean
required: false
default: ''
default: true
buildDocker:
description: 'Build Docker image'
type: boolean
required: false
default: true
pushDocker:
description: 'Push Docker image to registry'
type: boolean
required: false
default: false
buildBinaries:
description: 'Build executables and installers'
type: boolean
required: false
default: true
createRelease:
description: 'Create GitHub release'
type: boolean
required: false
default: false
jobs:
# Validate release
@@ -19,7 +43,7 @@ jobs:
app_version: ${{ steps.version.outputs.app_version }}
release_version: ${{ steps.version.outputs.release_version }}
is_tag: ${{ steps.version.outputs.is_tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -32,55 +56,127 @@ 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:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
uses: ./.github/workflows/test.yml
secrets: inherit
# Build frontend once for all build jobs and cache it
build-frontend:
needs: [validate, test]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-frontend.yml
secrets: inherit
# Build portable executables
build-executables:
needs: validate
needs: [validate, test, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-executable.yml
with:
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Build Windows installer
build-windows-installer:
needs: validate
needs: [validate, test, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-windows-installer.yml
with:
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Build macOS Intel installer
build-macos-intel:
needs: validate
uses: ./.github/workflows/build-macos-intel-installer.yml
# Build macOS installers (Intel and ARM)
build-macos:
needs: [validate, test, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-macos-installer.yml
with:
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Build macOS ARM installer
build-macos-arm:
needs: validate
uses: ./.github/workflows/build-macos-arm-installer.yml
# Build and push Docker image(s)
build-docker:
needs: [validate, test]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
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
create-release:
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
needs: [validate, build-executables, build-windows-installer, build-macos]
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
if: |
always() &&
needs.validate.result == 'success' &&
needs.build-executables.result == 'success' &&
needs.build-windows-installer.result == 'success' &&
needs.build-macos.result == 'success' &&
(
needs.validate.outputs.is_tag == 'true' ||
(github.event.inputs.createRelease == 'true' && github.event.inputs.buildBinaries == 'true')
)
steps:
- name: Get vault secrets
@@ -93,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
@@ -113,52 +226,62 @@ jobs:
target_commitish: main
generate_release_notes: true
files: |
./artifacts/**/*.zip
./artifacts/**/*.pkg
./artifacts/**/*.exe
./artifacts/*.zip
./artifacts/*.pkg
./artifacts/*.exe
# Summary job
summary:
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
runs-on: ubuntu-latest
if: always()
steps:
- name: Record workflow start time
id: workflow-start
run: |
# Get workflow start time from GitHub API
workflow_start=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }} --jq '.run_started_at')
start_epoch=$(date -d "$workflow_start" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$workflow_start" +%s)
echo "start=$start_epoch" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Summary
run: |
# Calculate total workflow duration
start_time=${{ steps.workflow-start.outputs.start }}
end_time=$(date +%s)
duration=$((end_time - start_time))
minutes=$((duration / 60))
seconds=$((duration % 60))
echo "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY
echo "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $GITHUB_STEP_SUMMARY
echo "**Total Duration**: ${minutes}m ${seconds}s" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Check job results
if [[ "${{ needs.build-executables.result }}" == "success" ]]; then
echo "✅ **Portable Executables**: Success" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Portable Executables**: ${{ needs.build-executables.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-windows-installer.result }}" == "success" ]]; then
echo "✅ **Windows Installer**: Success" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Windows Installer**: ${{ needs.build-windows-installer.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-macos-intel.result }}" == "success" ]]; then
echo "✅ **macOS Intel Installer**: Success" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **macOS Intel Installer**: ${{ needs.build-macos-intel.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-macos-arm.result }}" == "success" ]]; then
echo "✅ **macOS ARM Installer**: Success" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **macOS ARM Installer**: ${{ needs.build-macos-arm.result }}" >> $GITHUB_STEP_SUMMARY
fi
# Helper function to print job result
print_result() {
local name="$1"
local result="$2"
case "$result" in
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
esac
}
print_result "Tests" "${{ needs.test.result }}"
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
print_result "Portable Executables" "${{ needs.build-executables.result }}"
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
echo "" >> $GITHUB_STEP_SUMMARY
echo "🎉 **Build completed!**" >> $GITHUB_STEP_SUMMARY

99
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,99 @@
name: Tests
on:
push:
branches:
- main
paths:
- 'code/backend/**'
- '.github/workflows/test.yml'
pull_request:
paths:
- 'code/backend/**'
- '.github/workflows/test.yml'
workflow_call:
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Restore dependencies
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/cleanuparr.sln
- name: Build solution
run: dotnet build code/backend/cleanuparr.sln --configuration Release --no-restore
- 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" --settings code/backend/coverage.runsettings --results-directory ./coverage
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: ./coverage/*.trx
retention-days: 30
- name: Upload coverage reports
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: ./coverage/**/coverage.cobertura.xml
retention-days: 30
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage/**/coverage.cobertura.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
flags: backend
name: backend-coverage
- name: Test Summary
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.run-tests.outcome }}" == "success" ]; then
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
else
echo "❌ Tests failed or were cancelled. Status: ${{ steps.run-tests.outcome }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "Test artifacts have been uploaded for detailed analysis." >> $GITHUB_STEP_SUMMARY

66
.github/workflows/version-info.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Get Version Info
on:
workflow_call:
inputs:
manual_version:
description: 'Manual version override (e.g., 1.0.0)'
required: false
type: string
default: ''
outputs:
app_version:
description: 'Application version (without v prefix)'
value: ${{ jobs.version.outputs.app_version }}
release_version:
description: 'Release version (with v prefix)'
value: ${{ jobs.version.outputs.release_version }}
is_tag:
description: 'Whether this is a tag event'
value: ${{ jobs.version.outputs.is_tag }}
repository_name:
description: 'Repository name without owner'
value: ${{ jobs.version.outputs.repository_name }}
jobs:
version:
runs-on: ubuntu-latest
outputs:
app_version: ${{ steps.version.outputs.app_version }}
release_version: ${{ steps.version.outputs.release_version }}
is_tag: ${{ steps.version.outputs.is_tag }}
repository_name: ${{ steps.version.outputs.repository_name }}
steps:
- name: Calculate version info
id: version
run: |
repoFullName="${{ github.repository }}"
repositoryName="${repoFullName#*/}"
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag event
release_version="${GITHUB_REF##refs/tags/}"
app_version="${release_version#v}"
is_tag="true"
elif [[ -n "${{ inputs.manual_version }}" ]]; then
# Manual workflow with version
app_version="${{ inputs.manual_version }}"
release_version="v${app_version}"
is_tag="false"
else
# Development build
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
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 "repository_name=${repositoryName}" >> $GITHUB_OUTPUT
echo "📦 Repository: ${repositoryName}"
echo "🏷️ Release Version: ${release_version}"
echo "📱 App Version: ${app_version}"
echo "🔖 Is Tag: ${is_tag}"

View File

@@ -2,6 +2,11 @@ _Love this project? Give it a ⭐️ and let others know!_
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcleanuparr-status.pages.dev%2Fstatus.json&query=%24.version&logo=git&label=version&color=blue)
![Total Downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2FCleanuparr%2FCleanuparr%2Fcleanuparr&query=%24.downloadCount&style=flat&logo=docker&label=Total%20Downloads&color=blue)
[![Tests](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
[![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](https://discord.gg/SCtMCgtsc4)
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuparr can also trigger a search to replace the deleted shows/movies.

View File

@@ -1,10 +1,12 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
COPY frontend/package*.json ./
RUN npm ci && npm install -g @angular/cli
# Use cache mount for npm to speed up builds
RUN --mount=type=cache,target=/root/.npm \
npm ci && npm install -g @angular/cli
# Copy source code
COPY frontend/ .
@@ -28,14 +30,17 @@ EXPOSE 11011
# Copy source code
COPY backend/ ./backend/
# Restore dependencies
# Add NuGet source
RUN dotnet nuget add source --username ${PACKAGES_USERNAME} --password ${PACKAGES_PAT} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
# Build and publish
RUN dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
# Restore and publish with cache mount
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
dotnet restore ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj -a $TARGETARCH && \
dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
-a $TARGETARCH \
-c Release \
-o /app/publish \
--no-restore \
/p:Version=${VERSION} \
/p:PublishSingleFile=true \
/p:DebugSymbols=false

View File

@@ -23,27 +23,24 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.4.1" />
<PackageReference Include="MassTransit" Version="8.5.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<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="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<!-- API-related packages -->
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,7 @@
using System.Diagnostics;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Mvc;
@@ -14,18 +15,15 @@ public class StatusController : ControllerBase
{
private readonly ILogger<StatusController> _logger;
private readonly DataContext _dataContext;
private readonly DownloadServiceFactory _downloadServiceFactory;
private readonly ArrClientFactory _arrClientFactory;
private readonly IArrClientFactory _arrClientFactory;
public StatusController(
ILogger<StatusController> logger,
DataContext dataContext,
DownloadServiceFactory downloadServiceFactory,
ArrClientFactory arrClientFactory)
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_downloadServiceFactory = downloadServiceFactory;
_arrClientFactory = arrClientFactory;
}
@@ -179,7 +177,7 @@ public class StatusController : ControllerBase
try
{
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
await sonarrClient.TestConnectionAsync(instance);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
@@ -211,7 +209,7 @@ public class StatusController : ControllerBase
try
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
await radarrClient.TestConnectionAsync(instance);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
@@ -243,7 +241,7 @@ public class StatusController : ControllerBase
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
await lidarrClient.TestConnectionAsync(instance);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{

View File

@@ -2,7 +2,6 @@ using System.Text.Json.Serialization;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Hubs;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.OpenApi.Models;
using System.Text;
using Cleanuparr.Api.Middleware;
using Microsoft.Extensions.Options;
@@ -46,20 +45,6 @@ public static class ApiDI
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Cleanuparr API",
Version = "v1",
Description = "API for managing media downloads and cleanups",
Contact = new OpenApiContact
{
Name = "Cleanuparr Team"
}
});
});
return services;
}
@@ -83,17 +68,6 @@ public static class ApiDI
app.UseCors("Any");
app.UseRouting();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("v1/swagger.json", "Cleanuparr API v1");
options.RoutePrefix = "swagger";
options.DocumentTitle = "Cleanuparr API Documentation";
});
}
app.UseAuthorization();
app.MapControllers();

View File

@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
namespace Cleanuparr.Api.DependencyInjection;
@@ -12,6 +13,7 @@ public static class NotificationsDI
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

@@ -1,5 +1,7 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
@@ -10,7 +12,6 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
@@ -23,20 +24,18 @@ public static class ServicesDI
{
public static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddScoped<IEncryptionService, AesEncryptionService>()
.AddScoped<SensitiveDataJsonConverter>()
.AddScoped<EventsContext>()
.AddScoped<DataContext>()
.AddScoped<EventPublisher>()
.AddScoped<IEventPublisher, EventPublisher>()
.AddHostedService<EventCleanupService>()
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
.AddScoped<CertificateValidationService>()
.AddScoped<SonarrClient>()
.AddScoped<RadarrClient>()
.AddScoped<LidarrClient>()
.AddScoped<ReadarrClient>()
.AddScoped<WhisparrClient>()
.AddScoped<ArrClientFactory>()
.AddScoped<ISonarrClient, SonarrClient>()
.AddScoped<IRadarrClient, RadarrClient>()
.AddScoped<ILidarrClient, LidarrClient>()
.AddScoped<IReadarrClient, ReadarrClient>()
.AddScoped<IWhisparrClient, WhisparrClient>()
.AddScoped<IArrClientFactory, ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
@@ -45,17 +44,18 @@ public static class ServicesDI
.AddScoped<IDownloadHunter, DownloadHunter>()
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<UnixHardLinkFileService>()
.AddScoped<WindowsHardLinkFileService>()
.AddScoped<ArrQueueIterator>()
.AddScoped<DownloadServiceFactory>()
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
.AddScoped<IWindowsHardLinkFileService, WindowsHardLinkFileService>()
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddScoped<FileReader>()
.AddScoped<IRuleManager, RuleManager>()
.AddScoped<IRuleEvaluator, RuleEvaluator>()
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
.AddSingleton<IJobManagementService, JobManagementService>()
.AddSingleton<BlocklistProvider>()
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
.AddSingleton(TimeProvider.System)
.AddSingleton<AppStatusSnapshot>()
.AddHostedService<AppStatusRefreshService>();
}

View File

@@ -0,0 +1,24 @@
using System;
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
public sealed record TestArrInstanceRequest
{
[Required]
public required string Url { get; init; }
[Required]
public required string ApiKey { get; init; }
public ArrInstance ToTestInstance() => new()
{
Enabled = true,
Name = "Test Instance",
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = Guid.Empty,
};
}

View File

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Mapster;
@@ -20,13 +21,16 @@ public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IArrClientFactory _arrClientFactory;
public ArrConfigController(
ILogger<ArrConfigController> logger,
DataContext dataContext)
DataContext dataContext,
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
[HttpGet("sonarr")]
@@ -124,6 +128,26 @@ public sealed class ArrConfigController : ControllerBase
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Whisparr, id);
[HttpPost("sonarr/instances/test")]
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Sonarr, request);
[HttpPost("radarr/instances/test")]
public Task<IActionResult> TestRadarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Radarr, request);
[HttpPost("lidarr/instances/test")]
public Task<IActionResult> TestLidarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Lidarr, request);
[HttpPost("readarr/instances/test")]
public Task<IActionResult> TestReadarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Readarr, request);
[HttpPost("whisparr/instances/test")]
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Whisparr, request);
private async Task<IActionResult> GetArrConfig(InstanceType type)
{
await DataContext.Lock.WaitAsync();
@@ -260,6 +284,23 @@ public sealed class ArrConfigController : ControllerBase
}
}
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
{
try
{
var testInstance = request.ToTestInstance();
var client = _arrClientFactory.GetClient(type);
await client.HealthCheckAsync(testInstance);
return Ok(new { Message = $"Connection to {type} instance successful" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
}
}
private static string GetConfigActionName(InstanceType type) => type switch
{
InstanceType.Sonarr => nameof(GetSonarrConfig),

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -62,65 +62,13 @@ public sealed class DownloadCleanerConfigController : ControllerBase
throw new ValidationException("Request body cannot be null");
}
// Validate cron expression format
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
{
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
}
if (newConfigDto.Enabled && newConfigDto.Categories.Any())
{
if (newConfigDto.Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1))
{
throw new ValidationException("Duplicate category names found");
}
foreach (var categoryDto in newConfigDto.Categories)
{
if (string.IsNullOrWhiteSpace(categoryDto.Name))
{
throw new ValidationException("Category name cannot be empty");
}
if (categoryDto is { MaxRatio: < 0, MaxSeedTime: < 0 })
{
throw new ValidationException("Either max ratio or max seed time must be enabled");
}
if (categoryDto.MinSeedTime < 0)
{
throw new ValidationException("Min seed time cannot be negative");
}
}
}
if (newConfigDto.UnlinkedEnabled)
{
if (string.IsNullOrWhiteSpace(newConfigDto.UnlinkedTargetCategory))
{
throw new ValidationException("Unlinked target category cannot be empty");
}
if (newConfigDto.UnlinkedCategories?.Count is null or 0)
{
throw new ValidationException("Unlinked categories cannot be empty");
}
if (newConfigDto.UnlinkedCategories.Contains(newConfigDto.UnlinkedTargetCategory))
{
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
}
if (newConfigDto.UnlinkedCategories.Any(string.IsNullOrWhiteSpace))
{
throw new ValidationException("Empty unlinked category filter found");
}
if (!string.IsNullOrEmpty(newConfigDto.UnlinkedIgnoredRootDir) && !Directory.Exists(newConfigDto.UnlinkedIgnoredRootDir))
{
throw new ValidationException($"{newConfigDto.UnlinkedIgnoredRootDir} root directory does not exist");
}
}
// Get existing configuration
var oldConfig = await _dataContext.DownloadCleanerConfigs
.Include(x => x.Categories)
.FirstAsync();
@@ -132,25 +80,29 @@ 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
});
}
oldConfig.Validate();
await _dataContext.SaveChangesAsync();
await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner);

View File

@@ -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,
};
}

View File

@@ -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}" });
}
}
}

View File

@@ -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; } = [];
}

View File

@@ -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; } = [];
}

View File

@@ -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; } = [];
}

View File

@@ -44,6 +44,7 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -68,6 +69,7 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
_ => new object()
}
})
@@ -524,6 +526,7 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
@@ -583,12 +586,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -624,12 +627,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -670,12 +673,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Ntfy provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -701,8 +704,207 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
_ => new object()
}
};
}
[HttpPost("pushover")]
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var pushoverConfig = new PushoverConfig
{
ApiToken = newProvider.ApiToken,
UserKey = newProvider.UserKey,
Devices = newProvider.Devices,
Priority = newProvider.Priority,
Sound = newProvider.Sound,
Retry = newProvider.Retry,
Expire = newProvider.Expire,
Tags = newProvider.Tags
};
pushoverConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Pushover,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
PushoverConfiguration = pushoverConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Pushover provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("pushover/{id:guid}")]
public async Task<IActionResult> UpdatePushoverProvider(Guid id, [FromBody] UpdatePushoverProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.PushoverConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Pushover);
if (existingProvider == null)
{
return NotFound($"Pushover provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var pushoverConfig = new PushoverConfig
{
ApiToken = updatedProvider.ApiToken,
UserKey = updatedProvider.UserKey,
Devices = updatedProvider.Devices,
Priority = updatedProvider.Priority,
Sound = updatedProvider.Sound,
Retry = updatedProvider.Retry,
Expire = updatedProvider.Expire,
Tags = updatedProvider.Tags
};
if (existingProvider.PushoverConfiguration != null)
{
pushoverConfig = pushoverConfig with { Id = existingProvider.PushoverConfiguration.Id };
}
pushoverConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
PushoverConfiguration = pushoverConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Pushover provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("pushover/test")]
public async Task<IActionResult> TestPushoverProvider([FromBody] TestPushoverProviderRequest testRequest)
{
try
{
var pushoverConfig = new PushoverConfig
{
ApiToken = testRequest.ApiToken,
UserKey = testRequest.UserKey,
Devices = testRequest.Devices,
Priority = testRequest.Priority,
Sound = testRequest.Sound,
Retry = testRequest.Retry,
Expire = testRequest.Expire,
Tags = testRequest.Tags
};
pushoverConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Pushover,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = pushoverConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Pushover provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
}

View File

@@ -59,8 +59,6 @@ public sealed class QueueCleanerConfigController : ControllerBase
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
}
newConfigDto.FailedImport.Validate();
var oldConfig = await _dataContext.QueueCleanerConfigs
.FirstAsync();
@@ -70,6 +68,8 @@ public sealed class QueueCleanerConfigController : ControllerBase
oldConfig.FailedImport = newConfigDto.FailedImport;
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
oldConfig.Validate();
await _dataContext.SaveChangesAsync();

View File

@@ -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;

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
</Project>

View File

@@ -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.

View File

@@ -4,5 +4,6 @@ public enum NotificationProviderType
{
Notifiarr,
Apprise,
Ntfy
Ntfy,
Pushover
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Enums;
public enum PushoverPriority
{
Lowest = -2,
Low = -1,
Normal = 0,
High = 1,
Emergency = 2
}

View 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
];
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -6,6 +6,10 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
</ItemGroup>
@@ -16,19 +20,20 @@
<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="9.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,132 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
public class ArrClientFactoryTests
{
private readonly Mock<ISonarrClient> _sonarrClientMock;
private readonly Mock<IRadarrClient> _radarrClientMock;
private readonly Mock<ILidarrClient> _lidarrClientMock;
private readonly Mock<IReadarrClient> _readarrClientMock;
private readonly Mock<IWhisparrClient> _whisparrClientMock;
private readonly ArrClientFactory _factory;
public ArrClientFactoryTests()
{
_sonarrClientMock = new Mock<ISonarrClient>();
_radarrClientMock = new Mock<IRadarrClient>();
_lidarrClientMock = new Mock<ILidarrClient>();
_readarrClientMock = new Mock<IReadarrClient>();
_whisparrClientMock = new Mock<IWhisparrClient>();
_factory = new ArrClientFactory(
_sonarrClientMock.Object,
_radarrClientMock.Object,
_lidarrClientMock.Object,
_readarrClientMock.Object,
_whisparrClientMock.Object
);
}
#region GetClient Tests
[Fact]
public void GetClient_Sonarr_ReturnsSonarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Sonarr);
// Assert
Assert.Same(_sonarrClientMock.Object, result);
}
[Fact]
public void GetClient_Radarr_ReturnsRadarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Radarr);
// Assert
Assert.Same(_radarrClientMock.Object, result);
}
[Fact]
public void GetClient_Lidarr_ReturnsLidarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Lidarr);
// Assert
Assert.Same(_lidarrClientMock.Object, result);
}
[Fact]
public void GetClient_Readarr_ReturnsReadarrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Readarr);
// Assert
Assert.Same(_readarrClientMock.Object, result);
}
[Fact]
public void GetClient_Whisparr_ReturnsWhisparrClient()
{
// Act
var result = _factory.GetClient(InstanceType.Whisparr);
// Assert
Assert.Same(_whisparrClientMock.Object, result);
}
[Fact]
public void GetClient_UnsupportedType_ThrowsNotImplementedException()
{
// Arrange
var unsupportedType = (InstanceType)999;
// Act & Assert
var exception = Assert.Throws<NotImplementedException>(() => _factory.GetClient(unsupportedType));
Assert.Contains("not yet supported", exception.Message);
Assert.Contains("999", exception.Message);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public void GetClient_AllSupportedTypes_ReturnsNonNullClient(InstanceType instanceType)
{
// Act
var result = _factory.GetClient(instanceType);
// Assert
Assert.NotNull(result);
Assert.IsAssignableFrom<IArrClient>(result);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public void GetClient_CalledMultipleTimes_ReturnsSameInstance(InstanceType instanceType)
{
// Act
var result1 = _factory.GetClient(instanceType);
var result2 = _factory.GetClient(instanceType);
// Assert
Assert.Same(result1, result2);
}
#endregion
}

View File

@@ -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
}

View File

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

View File

@@ -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);
}
}

View File

@@ -0,0 +1,792 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
{
private readonly DelugeServiceFixture _fixture;
public DelugeServiceDCTests(DelugeServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class GetSeedingDownloads_Tests : DelugeServiceDCTests
{
public GetSeedingDownloads_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FiltersSeedingState()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "Downloading", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, item => Assert.NotNull(item.Hash));
}
[Fact]
public async Task IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "SEEDING", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Equal(2, result.Count);
}
[Fact]
public async Task ReturnsEmptyList_WhenNull()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync((List<DownloadStatus>?)null);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SkipsTorrentsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<DownloadStatus>
{
new DownloadStatus { Hash = "", Name = "No Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
};
_fixture.ClientWrapper
.Setup(x => x.GetStatusForAllTorrents())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : DelugeServiceDCTests
{
public FilterDownloadsToBeCleanedAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, x => x.Category == "movies");
Assert.Contains(result, x => x.Category == "tv");
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void ReturnsEmptyList_WhenNoMatches()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class FilterDownloadsToChangeCategoryAsync_Tests : DelugeServiceDCTests
{
public FilterDownloadsToChangeCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void FiltersCorrectly()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void SkipsDownloadsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class CreateCategoryAsync_Tests : DelugeServiceDCTests
{
public CreateCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CreatesLabel_WhenMissing()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.CreateLabel("new-label"))
.Returns(Task.CompletedTask);
// Act
await sut.CreateCategoryAsync("new-label");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel("new-label"), Times.Once);
}
[Fact]
public async Task SkipsCreation_WhenLabelExists()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string> { "existing" });
// Act
await sut.CreateCategoryAsync("existing");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetLabels())
.ReturnsAsync(new List<string> { "Existing" });
// Act
await sut.CreateCategoryAsync("existing");
// Assert
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
}
}
public class DeleteDownload_Tests : DelugeServiceDCTests
{
public DeleteDownload_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CallsClientDelete()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true),
Times.Once);
}
[Fact]
public async Task NormalizesHashToLowercase()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
Times.Once);
}
[Fact]
public async Task CallsClientDeleteWithoutSourceFiles()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false),
Times.Once);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : DelugeServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NullDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task EmptyDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingHash_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingName_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingCategory_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ExceptionGettingFiles_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task NoHardlinks_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabel("hash1", "unlinked"),
Times.Once);
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task FileNotFound_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SkippedFiles_IgnoredInCheck()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "file1.mkv" } },
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "file2.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
}
[Fact]
public async Task WithIgnoredRootDir_PopulatesFileCounts()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}
[Fact]
public async Task PublishesCategoryChangedEvent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles("hash1"))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabel("hash1", "unlinked"),
Times.Once);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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
}

View File

@@ -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>());
@@ -425,13 +454,13 @@ public class QBitItemTests
}
[Fact]
public void IsIgnored_MatchingName_ReturnsTrue()
public void IsIgnored_MatchingHash_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "test" };
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "abc123" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
@@ -441,13 +470,39 @@ public class QBitItemTests
}
[Fact]
public void IsIgnored_MatchingHash_ReturnsTrue()
public void IsIgnored_MatchingTag_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123",
Tags = new List<string> { "test-tag" }.AsReadOnly()
};
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "abc123" };
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);
@@ -460,12 +515,16 @@ public class QBitItemTests
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
@@ -479,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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,906 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixture>
{
private readonly TransmissionServiceFixture _fixture;
public TransmissionServiceDCTests(TransmissionServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class GetSeedingDownloads_Tests : TransmissionServiceDCTests
{
public GetSeedingDownloads_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FiltersStatus5And6()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { HashString = "hash1", Name = "Torrent 1", Status = 5 }, // Seeding
new TorrentInfo { HashString = "hash2", Name = "Torrent 2", Status = 4 }, // Downloading
new TorrentInfo { HashString = "hash3", Name = "Torrent 3", Status = 6 } // Seeding
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, item => Assert.NotNull(item.Hash));
}
[Fact]
public async Task ReturnsEmptyList_WhenNull()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync((TransmissionTorrents?)null);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SkipsTorrentsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { HashString = "", Name = "No Hash", Status = 5 },
new TorrentInfo { HashString = "hash1", Name = "Valid Hash", Status = 5 }
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public async Task ReturnsEmptyList_WhenTorrentsNull()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new TransmissionTorrents
{
Torrents = null
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
.ReturnsAsync(torrents);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : TransmissionServiceDCTests
{
public FilterDownloadsToBeCleanedAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash3", DownloadDir = "/downloads/music" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, x => x.Category == "movies");
Assert.Contains(result, x => x.Category == "tv");
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void ReturnsEmptyList_WhenNoMatches()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class FilterDownloadsToChangeCategoryAsync_Tests : TransmissionServiceDCTests
{
public FilterDownloadsToChangeCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void FiltersCorrectly()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void SkipsDownloadsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "", DownloadDir = "/downloads/movies" }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class CreateCategoryAsync_Tests : TransmissionServiceDCTests
{
public CreateCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task IsNoOp()
{
// Arrange
var sut = _fixture.CreateSut();
// Act
await sut.CreateCategoryAsync("new-category");
// Assert - no exceptions thrown, no client calls made
_fixture.ClientWrapper.VerifyNoOtherCalls();
}
}
public class DeleteDownload_Tests : TransmissionServiceDCTests
{
public DeleteDownload_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task GetsIdFromHash_ThenDeletes()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { Id = 123, HashString = hash }
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.ClientWrapper
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true),
Times.Once);
}
[Fact]
public async Task HandlesNotFound()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "nonexistent-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync((TransmissionTorrents?)null);
// Act
await sut.DeleteDownload(hash, true);
// Assert - no exception thrown
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), It.IsAny<bool>()),
Times.Never);
}
[Fact]
public async Task DeletesWithData()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { Id = 123, HashString = hash }
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
// Act
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true),
Times.Once);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : TransmissionServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NullDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task EmptyDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingHash_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "", Name = "Test", DownloadDir = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingName_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "", DownloadDir = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingDownloadDir_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingFiles_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "/downloads", Files = null })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task MissingFileStats_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads",
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = null
})
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task NoHardlinks_ChangesLocation()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = baseDownloadDir,
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads/movies",
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task FileNotFound_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads/movies",
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
[Fact]
public async Task UnwantedFiles_IgnoredInCheck()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads/movies",
Files = new[]
{
new TransmissionTorrentFiles { Name = "file1.mkv" },
new TransmissionTorrentFiles { Name = "file2.mkv" }
},
FileStats = new[]
{
new TransmissionTorrentFileStats { Wanted = false },
new TransmissionTorrentFileStats { Wanted = true }
}
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
}
[Fact]
public async Task WithIgnoredRootDir_PopulatesFileCounts()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = "/downloads/movies",
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}
[Fact]
public async Task PublishesCategoryChangedEvent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = baseDownloadDir,
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
}
[Fact]
public async Task AppendsTargetCategoryToBasePath()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var baseDownloadDir = Path.Combine("downloads", "movies", "subfolder");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = baseDownloadDir,
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
Times.Once);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

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

View File

@@ -0,0 +1,744 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
{
private readonly UTorrentServiceFixture _fixture;
public UTorrentServiceDCTests(UTorrentServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class GetSeedingDownloads_Tests : UTorrentServiceDCTests
{
public GetSeedingDownloads_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FiltersSeedingTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new List<UTorrentItem>
{
new UTorrentItem { Hash = "hash1", Name = "Torrent 1", Status = 9, DateCompleted = 1000 }, // Seeding (Started + Checked, DateCompleted > 0)
new UTorrentItem { Hash = "hash2", Name = "Torrent 2", Status = 9, DateCompleted = 0 }, // Downloading (Started + Checked, DateCompleted = 0)
new UTorrentItem { Hash = "hash3", Name = "Torrent 3", Status = 9, DateCompleted = 2000 } // Seeding (Started + Checked, DateCompleted > 0)
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash3"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" });
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Equal(2, result.Count);
}
[Fact]
public async Task ReturnsEmptyList_WhenNoSeedingTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new List<UTorrentItem>
{
new UTorrentItem { Hash = "hash1", Name = "Torrent 1", Status = 9 } // Not seeding
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SkipsTorrentsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var torrents = new List<UTorrentItem>
{
new UTorrentItem { Hash = "", Name = "No Hash", Status = 9, DateCompleted = 1000 },
new UTorrentItem { Hash = "hash1", Name = "Valid Hash", Status = 9, DateCompleted = 1000 }
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentsAsync())
.ReturnsAsync(torrents);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : UTorrentServiceDCTests
{
public FilterDownloadsToBeCleanedAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }),
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash2", Label = "tv" }, new UTorrentProperties { Hash = "hash2", Pex = 1, Trackers = "" }),
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash3", Label = "music" }, new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, x => x.Category == "movies");
Assert.Contains(result, x => x.Category == "tv");
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void ReturnsEmptyList_WhenNoMatches()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "music" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
}
public class FilterDownloadsToChangeCategoryAsync_Tests : UTorrentServiceDCTests
{
public FilterDownloadsToChangeCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void FiltersCorrectly()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }),
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash2", Label = "tv" }, new UTorrentProperties { Hash = "hash2", Pex = 1, Trackers = "" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void SkipsDownloadsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(new UTorrentItem { Hash = "", Label = "movies" }, new UTorrentProperties { Hash = "", Pex = 1, Trackers = "" }),
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("hash1", result[0].Hash);
}
}
public class CreateCategoryAsync_Tests : UTorrentServiceDCTests
{
public CreateCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task IsNoOp()
{
// Arrange
var sut = _fixture.CreateSut();
// Act
await sut.CreateCategoryAsync("new-category");
// Assert - no exceptions thrown, no client calls made
}
}
public class DeleteDownload_Tests : UTorrentServiceDCTests
{
public DeleteDownload_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CallsClientDelete()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true),
Times.Once);
}
[Fact]
public async Task NormalizesHashToLowercase()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
Times.Once);
}
[Fact]
public async Task CallsClientDeleteWithoutSourceFiles()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false),
Times.Once);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : UTorrentServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NullDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task EmptyDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingHash_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "", Pex = 1, Trackers = "" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingName_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task MissingCategory_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task NoHardlinks_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
Times.Once);
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(2);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task FileNotFound_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(-1);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SkippedFiles_IgnoredInCheck()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
}
[Fact]
public async Task WithIgnoredRootDir_PopulatesFileCounts()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}
[Fact]
public async Task PublishesCategoryChangedEvent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - EventPublisher is not mocked, so we just verify the method completed
_fixture.ClientWrapper.Verify(
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
Times.Once);
}
[Fact]
public async Task NullFilesResponse_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("hash1"))
.ReturnsAsync((List<UTorrentFile>?)null);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - When files is null, it uses empty collection and proceeds to change label
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync("hash1", "unlinked"), Times.Once);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,311 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Data.Models.Arr;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Time.Testing;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter;
public class DownloadHunterTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly FakeTimeProvider _fakeTimeProvider;
private readonly Infrastructure.Features.DownloadHunter.DownloadHunter _downloadHunter;
private readonly SqliteConnection _connection;
public DownloadHunterTests()
{
// Use SQLite in-memory with shared connection to support complex types
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(_connection)
.Options;
_dataContext = new DataContext(options);
_dataContext.Database.EnsureCreated();
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_fakeTimeProvider = new FakeTimeProvider();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
.Returns(_arrClientMock.Object);
_downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter(
_dataContext,
_arrClientFactoryMock.Object,
_fakeTimeProvider
);
}
public void Dispose()
{
_dataContext.Dispose();
_connection.Dispose();
}
#region HuntDownloadsAsync - Search Disabled Tests
[Fact]
public async Task HuntDownloadsAsync_WhenSearchDisabled_DoesNotCallArrClient()
{
// Arrange
await SetupGeneralConfig(searchEnabled: false);
var request = CreateHuntRequest();
// Act
await _downloadHunter.HuntDownloadsAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>()), Times.Never);
_arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()), Times.Never);
}
[Fact]
public async Task HuntDownloadsAsync_WhenSearchDisabled_ReturnsImmediately()
{
// Arrange
await SetupGeneralConfig(searchEnabled: false);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Should complete without needing to advance time
var completedTask = await Task.WhenAny(task, Task.Delay(100));
Assert.Same(task, completedTask);
}
#endregion
#region HuntDownloadsAsync - Search Enabled Tests
[Fact]
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsArrClientFactory()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act - Start the task and advance time
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType), Times.Once);
}
[Fact]
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsSearchItemsAsync()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientMock.Verify(
c => c.SearchItemsAsync(
request.Instance,
It.Is<HashSet<SearchItem>>(s => s.Contains(request.SearchItem))),
Times.Once);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task HuntDownloadsAsync_UsesCorrectInstanceType(InstanceType instanceType)
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest(instanceType);
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
}
#endregion
#region HuntDownloadsAsync - Delay Tests
[Fact]
public async Task HuntDownloadsAsync_WaitsForConfiguredDelay()
{
// Arrange
const ushort configuredDelay = 120;
await SetupGeneralConfig(searchEnabled: true, searchDelay: configuredDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Task should not complete before advancing time
Assert.False(task.IsCompleted);
// Advance partial time - should still not complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(configuredDelay - 1));
await Task.Delay(10); // Give the task a chance to complete if it would
Assert.False(task.IsCompleted);
// Advance remaining time - should now complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayBelowMinimum_UsesDefaultDelay()
{
// Arrange - Set delay below minimum (simulating manual DB edit)
const ushort belowMinDelay = 10; // Below MinSearchDelaySeconds (60)
await SetupGeneralConfig(searchEnabled: true, searchDelay: belowMinDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by the below-min value - should NOT complete because it should use default
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(belowMinDelay));
await Task.Delay(10);
Assert.False(task.IsCompleted);
// Advance to default delay - should now complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds - belowMinDelay));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayIsZero_UsesDefaultDelay()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: 0);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Should not complete immediately
Assert.False(task.IsCompleted);
// Advance to default delay
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayAtMinimum_UsesConfiguredDelay()
{
// Arrange - Set delay exactly at minimum
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by minimum - should complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayAboveMinimum_UsesConfiguredDelay()
{
// Arrange - Set delay above minimum
const ushort aboveMinDelay = 180;
await SetupGeneralConfig(searchEnabled: true, searchDelay: aboveMinDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by minimum - should NOT complete yet
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await Task.Delay(10);
Assert.False(task.IsCompleted);
// Advance remaining time
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(aboveMinDelay - Constants.MinSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
#endregion
#region Helper Methods
private async Task SetupGeneralConfig(bool searchEnabled, ushort searchDelay = Constants.DefaultSearchDelaySeconds)
{
var generalConfig = new GeneralConfig
{
SearchEnabled = searchEnabled,
SearchDelay = searchDelay
};
_dataContext.GeneralConfigs.Add(generalConfig);
await _dataContext.SaveChangesAsync();
}
private static DownloadHuntRequest<SearchItem> CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr)
{
return new DownloadHuntRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
#endregion
}

View File

@@ -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
}

View File

@@ -0,0 +1,484 @@
using System.Net;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
public class QueueItemRemoverTests : IDisposable
{
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
private readonly Mock<IBus> _busMock;
private readonly MemoryCache _memoryCache;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly EventPublisher _eventPublisher;
private readonly EventsContext _eventsContext;
private readonly QueueItemRemover _queueItemRemover;
public QueueItemRemoverTests()
{
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
_busMock = new Mock<IBus>();
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
.Returns(_arrClientMock.Object);
// Create real EventPublisher with mocked dependencies
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_eventsContext = new EventsContext(eventsContextOptions);
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
// Setup interceptor to execute the action with params using DynamicInvoke
dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
var result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
});
_eventPublisher = new EventPublisher(
_eventsContext,
hubContextMock.Object,
Mock.Of<ILogger<EventPublisher>>(),
Mock.Of<INotificationPublisher>(),
dryRunInterceptorMock.Object);
_queueItemRemover = new QueueItemRemover(
_loggerMock.Object,
_busMock.Object,
_memoryCache,
_arrClientFactoryMock.Object,
_eventPublisher
);
// Clear static RecurringHashes before each test
Striker.RecurringHashes.Clear();
}
public void Dispose()
{
_memoryCache.Dispose();
_eventsContext.Dispose();
Striker.RecurringHashes.Clear();
}
#region RemoveQueueItemAsync - Success Tests
[Fact]
public async Task RemoveQueueItemAsync_Success_DeletesQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
request.Instance,
request.Record,
request.RemoveFromClient,
request.DeleteReason), Times.Once);
}
[Fact]
public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
DownloadHuntRequest<SearchItem>? capturedRequest = null;
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
_busMock
.Setup(b => b.Publish(It.IsAny<DownloadHuntRequest<SearchItem>>(), It.IsAny<CancellationToken>()))
.Callback<DownloadHuntRequest<SearchItem>, CancellationToken>((r, _) => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest!.InstanceType);
Assert.Equal(request.Instance, capturedRequest.Instance);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
}
[Fact]
public async Task RemoveQueueItemAsync_Success_ClearsDownloadMarkedForRemovalCache()
{
// Arrange
var request = CreateRemoveRequest();
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
_memoryCache.Set(cacheKey, true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task RemoveQueueItemAsync_UsesCorrectClientForInstanceType(InstanceType instanceType)
{
// Arrange
var request = CreateRemoveRequest(instanceType);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
}
#endregion
#region RemoveQueueItemAsync - Recurring Hash Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
var hash = request.Record.DownloadId.ToLowerInvariant();
Striker.RecurringHashes.TryAdd(hash, null);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_RemovesHashFromRecurring()
{
// Arrange
var request = CreateRemoveRequest();
var hash = request.Record.DownloadId.ToLowerInvariant();
Striker.RecurringHashes.TryAdd(hash, null);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
}
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
}
#endregion
#region RemoveQueueItemAsync - HTTP Error Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenNotFoundError_ThrowsWithItemAlreadyDeletedMessage()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Contains("might have already been deleted", exception.Message);
Assert.Contains(request.InstanceType.ToString(), exception.Message);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenNotFoundError_ClearsCacheInFinally()
{
// Arrange
var request = CreateRemoveRequest();
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
_memoryCache.Set(cacheKey, true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
// Cache should be cleared in finally block
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
}
[Fact]
public async Task RemoveQueueItemAsync_WhenOtherHttpError_Rethrows()
{
// Arrange
var request = CreateRemoveRequest();
var originalException = new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<HttpRequestException>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Same(originalException, exception);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenNonHttpError_Rethrows()
{
// Arrange
var request = CreateRemoveRequest();
var originalException = new InvalidOperationException("Some other error");
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Same(originalException, exception);
}
#endregion
#region RemoveQueueItemAsync - Delete Reason Tests
[Theory]
[InlineData(DeleteReason.Stalled)]
[InlineData(DeleteReason.FailedImport)]
[InlineData(DeleteReason.SlowSpeed)]
[InlineData(DeleteReason.SlowTime)]
[InlineData(DeleteReason.DownloadingMetadata)]
[InlineData(DeleteReason.MalwareFileFound)]
public async Task RemoveQueueItemAsync_PassesCorrectDeleteReason(DeleteReason deleteReason)
{
// Arrange
var request = CreateRemoveRequest(deleteReason: deleteReason);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
deleteReason), Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RemoveQueueItemAsync_PassesCorrectRemoveFromClientFlag(bool removeFromClient)
{
// Arrange
var request = CreateRemoveRequest(removeFromClient: removeFromClient);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
removeFromClient,
It.IsAny<DeleteReason>()), Times.Once);
}
#endregion
#region Helper Methods
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
InstanceType instanceType = InstanceType.Sonarr,
bool removeFromClient = true,
DeleteReason deleteReason = DeleteReason.Stalled)
{
return new QueueItemRemoveRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123DEF456"
};
}
#endregion
}

View File

@@ -0,0 +1,916 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
[Collection(JobHandlerCollection.Name)]
public class DownloadCleanerTests : IDisposable
{
private readonly JobHandlerFixture _fixture;
private readonly Mock<ILogger<DownloadCleaner>> _logger;
public DownloadCleanerTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = _fixture.CreateLogger<DownloadCleaner>();
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
private DownloadCleaner CreateSut()
{
return new DownloadCleaner(
_logger.Object,
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus.Object,
_fixture.ArrClientFactory.Object,
_fixture.ArrQueueIterator.Object,
_fixture.DownloadServiceFactory.Object,
_fixture.EventPublisher.Object,
_fixture.TimeProvider
);
}
/// <summary>
/// Executes the handler and advances time past the 10-second delay
/// </summary>
private async Task ExecuteWithTimeAdvance(DownloadCleaner sut)
{
var task = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
await task;
}
#region ExecuteAsync Tests (inherited from GenericHandler)
[Fact]
public async Task ExecuteAsync_LoadsAllConfigsIntoContextProvider()
{
// Arrange
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - verify configs were loaded (by checking the handler completed without errors)
// The configs are loaded into ContextProvider which is AsyncLocal scoped
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no download clients")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region ExecuteInternalAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
{
// Arrange
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no download clients are configured")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenNoFeaturesEnabled_LogsWarningAndReturns()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - should warn about no seeding downloads or no features enabled
// The exact message depends on the order of checks
_logger.Verify(
x => x.Log(
It.IsAny<LogLevel>(),
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.AtLeastOnce
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenNoSeedingDownloadsFound_LogsInfoAndReturns()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No seeding downloads found")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_FiltersOutIgnoredDownloads()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
// Add ignored download to general config
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
generalConfig.IgnoredDownloads = ["ignored-hash"];
_fixture.DataContext.SaveChanges();
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("ignored-hash");
mockTorrent.Setup(x => x.Name).Returns("Ignored Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - the download should be skipped
_logger.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("download is ignored")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_FiltersOutDownloadsUsedByArrs()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("arr-download-hash");
mockTorrent.Setup(x => x.Name).Returns("Arr Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
// Setup arr client to return queue record with matching download ID
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "arr-download-hash",
Title = "Test Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - the download should be skipped because it's used by an arr
_logger.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("download is used by an arr")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_ProcessesAllArrConfigs()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
// Need at least one download for arr processing to occur
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
))
.Returns([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(Task.CompletedTask);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - both instances should be processed
_fixture.ArrClientFactory.Verify(
x => x.GetClient(InstanceType.Sonarr),
Times.Once
);
_fixture.ArrClientFactory.Verify(
x => x.GetClient(InstanceType.Radarr),
Times.Once
);
}
#endregion
#region ChangeUnlinkedCategoriesAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenUnlinkedEnabled_EvaluatesDownloadsForHardlinks()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.Returns(Task.CompletedTask);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("hardlinks")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region CleanDownloadsAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenCategoriesConfigured_EvaluatesDownloadsForCleaning()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext, "completed", 1.0, 60);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
))
.Returns(Task.CompletedTask);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("cleanup")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region ProcessInstanceAsync Tests
[Fact]
public async Task ProcessInstanceAsync_CollectsDownloadIdsFromArrQueue()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
// Need at least one download for arr processing to occur
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
))
.Returns([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecords = new List<QueueRecord>
{
new() { Id = 1, DownloadId = "hash1", Title = "Download 1", Protocol = "torrent" },
new() { Id = 2, DownloadId = "hash2", Title = "Download 2", Protocol = "torrent" }
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
mockArrClient.Object,
It.Is<ArrInstance>(i => i.Id == sonarrInstance.Id),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback(queueRecords);
});
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert - verify the iterator was called
_fixture.ArrQueueIterator.Verify(
x => x.Iterate(
mockArrClient.Object,
It.Is<ArrInstance>(i => i.Id == sonarrInstance.Id),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
),
Times.Once
);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task ExecuteInternalAsync_WhenDownloadServiceFails_LogsErrorAndContinues()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Failing Client");
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Working Client");
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var failingService = _fixture.CreateMockDownloadService("Failing Client");
failingService
.Setup(x => x.GetSeedingDownloads())
.ThrowsAsync(new Exception("Connection failed"));
var workingService = _fixture.CreateMockDownloadService("Working Client");
workingService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([]);
var callCount = 0;
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(() =>
{
callCount++;
return callCount == 1 ? failingService.Object : workingService.Object;
});
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to get seeding downloads")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ChangeUnlinkedCategoriesAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
))
.Throws(new Exception("Filter failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for hardlinks evaluation")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ChangeUnlinkedCategoriesAsync_WhenCreateCategoryThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.ThrowsAsync(new Exception("Create category failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to create category")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ChangeUnlinkedCategoriesAsync_WhenChangeCategoryThrows_LogsErrorAndContinues()
{
// Arrange
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
downloadCleanerConfig.UnlinkedEnabled = true;
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
downloadCleanerConfig.UnlinkedCategories = ["completed"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<string>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockDownloadService
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
.ThrowsAsync(new Exception("Change category failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to change category for download client")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task CleanDownloadsAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
))
.Throws(new Exception("Filter failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for cleaning")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task CleanDownloadsAsync_WhenCleanDownloadsThrows_LogsErrorAndContinues()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
))
.ThrowsAsync(new Exception("Clean failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to clean downloads for download client")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ProcessArrConfigAsync_WhenArrIteratorThrows_LogsErrorAndRethrows()
{
// Arrange - DownloadCleaner calls ProcessArrConfigAsync with throwOnFailure=true
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
mockTorrent.Setup(x => x.Name).Returns("Test Download");
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
mockTorrent.Setup(x => x.Category).Returns("completed");
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.GetSeedingDownloads())
.ReturnsAsync([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<SeedingRule>>()
))
.Returns([]);
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
// Make the arr queue iterator throw an exception
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.ThrowsAsync(new InvalidOperationException("Arr connection failed"));
var sut = CreateSut();
// Act & Assert - exception should propagate since throwOnFailure=true
// Need to advance time for the delay to pass before the exception is thrown
var task = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => task);
Assert.Equal("Arr connection failed", exception.Message);
// Verify error was logged
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to process")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
}

View File

@@ -0,0 +1,609 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using MalwareBlockerJob = Cleanuparr.Infrastructure.Features.Jobs.MalwareBlocker;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
[Collection(JobHandlerCollection.Name)]
public class MalwareBlockerTests : IDisposable
{
private readonly JobHandlerFixture _fixture;
private readonly Mock<ILogger<MalwareBlockerJob>> _logger;
public MalwareBlockerTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = _fixture.CreateLogger<MalwareBlockerJob>();
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
private MalwareBlockerJob CreateSut()
{
return new MalwareBlockerJob(
_logger.Object,
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus.Object,
_fixture.ArrClientFactory.Object,
_fixture.ArrQueueIterator.Object,
_fixture.DownloadServiceFactory.Object,
_fixture.BlocklistProvider.Object,
_fixture.EventPublisher.Object
);
}
#region ExecuteInternalAsync Tests
[Fact]
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
{
// Arrange
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No download clients configured")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenNoBlocklistsEnabled_LogsWarningAndReturns()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No blocklists are enabled")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenBlocklistEnabled_LoadsBlocklists()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(Task.CompletedTask);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_fixture.BlocklistProvider.Verify(x => x.LoadBlocklistsAsync(), Times.Once);
}
[Fact]
public async Task ExecuteInternalAsync_WhenSonarrEnabled_ProcessesSonarrInstances()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(Task.CompletedTask);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
}
[Fact]
public async Task ExecuteInternalAsync_WhenDeleteKnownMalwareEnabled_ProcessesAllArrs()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
contentBlockerConfig.DeleteKnownMalware = true;
// Need at least one blocklist enabled for processing to occur
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrClientFactory
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
.Returns(mockArrClient.Object);
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(Task.CompletedTask);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - Sonarr and Radarr processed because DeleteKnownMalware is true
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), Times.Once);
}
#endregion
#region ProcessInstanceAsync Tests
[Fact]
public async Task ProcessInstanceAsync_SkipsIgnoredDownloads()
{
// Arrange
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
generalConfig.IgnoredDownloads = ["ignored-download-id"];
_fixture.DataContext.SaveChanges();
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "ignored-download-id",
Title = "Ignored Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ignored")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public async Task ProcessInstanceAsync_ChecksTorrentClientsForBlockedFiles()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "torrent-download-id",
Title = "Torrent Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult { Found = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
mockDownloadService.Verify(
x => x.BlockUnwantedFilesAsync("torrent-download-id", It.IsAny<List<string>>()),
Times.Once
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemove_PublishesRemoveRequest()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "malware-download-id",
Title = "Malware Download",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.DeleteReason == DeleteReason.AllFilesBlocked
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenPrivateAndDeletePrivateFalse_DoesNotRemoveFromClient()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
// Ensure DeletePrivate is false
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
contentBlockerConfig.DeletePrivate = false;
_fixture.DataContext.SaveChanges();
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "private-malware-id",
Title = "Private Malware",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
IsPrivate = true,
DeleteReason = DeleteReason.AllFilesBlocked
});
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert - RemoveFromClient should be false
_fixture.MessageBus.Verify(
x => x.Publish(
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.RemoveFromClient == false
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenDownloadNotFoundInTorrentClient_LogsWarning()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "missing-download-id",
Title = "Missing Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ReturnsAsync(new BlockFilesResult { Found = false });
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Download not found in any torrent client")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task ProcessInstanceAsync_WhenDownloadServiceThrows_LogsErrorAndContinues()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = new Mock<IArrClient>();
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr))
.Returns(mockArrClient.Object);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "error-download-id",
Title = "Error Download",
Protocol = "torrent"
};
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(
It.IsAny<IArrClient>(),
It.IsAny<ArrInstance>(),
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
))
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
{
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.Setup(x => x.BlockUnwantedFilesAsync(
It.IsAny<string>(),
It.IsAny<List<string>>()
))
.ThrowsAsync(new Exception("Connection failed"));
_fixture.DownloadServiceFactory
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
.Returns(mockDownloadService.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
_logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error checking download")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
#endregion
#region Helper Methods
private void EnableSonarrBlocklist()
{
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
_fixture.DataContext.SaveChanges();
}
#endregion
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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";
}

View File

@@ -0,0 +1,130 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Persistence;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// Base fixture for job handler tests providing common mock dependencies
/// </summary>
public class JobHandlerFixture : IDisposable
{
public DataContext DataContext { get; private set; }
public MemoryCache Cache { get; }
public Mock<IBus> MessageBus { get; }
public Mock<IArrClientFactory> ArrClientFactory { get; }
public Mock<IArrQueueIterator> ArrQueueIterator { get; }
public Mock<IDownloadServiceFactory> DownloadServiceFactory { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public FakeTimeProvider TimeProvider { get; private set; }
public JobHandlerFixture()
{
DataContext = TestDataContextFactory.Create();
Cache = new MemoryCache(new MemoryCacheOptions());
MessageBus = new Mock<IBus>();
ArrClientFactory = new Mock<IArrClientFactory>();
ArrQueueIterator = new Mock<IArrQueueIterator>();
DownloadServiceFactory = new Mock<IDownloadServiceFactory>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = new Mock<IBlocklistProvider>();
TimeProvider = new FakeTimeProvider();
// Setup default behaviors
SetupDefaultBehaviors();
}
private void SetupDefaultBehaviors()
{
// EventPublisher methods return completed task by default
EventPublisher
.Setup(x => x.PublishAsync(
It.IsAny<Domain.Enums.EventType>(),
It.IsAny<string>(),
It.IsAny<Domain.Enums.EventSeverity>(),
It.IsAny<object?>(),
It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
}
/// <summary>
/// Creates a mock logger for a specific handler type
/// </summary>
public Mock<ILogger<T>> CreateLogger<T>() where T : GenericHandler
{
return new Mock<ILogger<T>>();
}
/// <summary>
/// Creates a mock download service
/// </summary>
public Mock<IDownloadService> CreateMockDownloadService(string clientName = "Test Client")
{
var mock = new Mock<IDownloadService>();
mock.Setup(x => x.ClientConfig).Returns(new Persistence.Models.Configuration.DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = clientName,
Type = Domain.Enums.DownloadClientType.Torrent,
TypeName = Domain.Enums.DownloadClientTypeName.qBittorrent,
Enabled = true,
Host = new Uri("http://localhost:8080")
});
mock.Setup(x => x.LoginAsync()).Returns(Task.CompletedTask);
return mock;
}
/// <summary>
/// Sets up the DownloadServiceFactory to return the specified mock services
/// </summary>
public void SetupDownloadServices(params Mock<IDownloadService>[] services)
{
foreach (var service in services)
{
DownloadServiceFactory
.Setup(x => x.GetDownloadService(service.Object.ClientConfig))
.Returns(service.Object);
}
}
/// <summary>
/// Creates a fresh DataContext, disposing the old one
/// </summary>
public DataContext RecreateDataContext(bool seedData = true)
{
DataContext?.Dispose();
DataContext = TestDataContextFactory.Create(seedData);
return DataContext;
}
public void ResetMocks()
{
MessageBus.Reset();
ArrClientFactory.Reset();
ArrQueueIterator.Reset();
DownloadServiceFactory.Reset();
EventPublisher.Reset();
BlocklistProvider.Reset();
Cache.Clear();
TimeProvider = new FakeTimeProvider();
SetupDefaultBehaviors();
}
public void Dispose()
{
DataContext?.Dispose();
Cache?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,336 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// Factory for creating SQLite in-memory DataContext instances for testing
/// </summary>
public static class TestDataContextFactory
{
/// <summary>
/// Creates a new SQLite in-memory DataContext with default seed data
/// </summary>
public static DataContext Create(bool seedData = true)
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connection)
.Options;
var context = new DataContext(options);
context.Database.EnsureCreated();
if (seedData)
{
SeedDefaultData(context);
}
return context;
}
/// <summary>
/// Seeds the minimum required data for GenericHandler.ExecuteAsync() to work
/// </summary>
private static void SeedDefaultData(DataContext context)
{
// General config
context.GeneralConfigs.Add(new GeneralConfig
{
Id = Guid.NewGuid(),
DryRun = false,
IgnoredDownloads = [],
Log = new LoggingConfig()
});
// Arr configs for all instance types
context.ArrConfigs.AddRange(
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
);
// Queue cleaner config
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
FailedImport = new FailedImportConfig()
});
// Content blocker config
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
DeleteKnownMalware = false,
DeletePrivate = false,
Sonarr = new BlocklistSettings { Enabled = false },
Radarr = new BlocklistSettings { Enabled = false },
Lidarr = new BlocklistSettings { Enabled = false },
Readarr = new BlocklistSettings { Enabled = false },
Whisparr = new BlocklistSettings { Enabled = false }
});
// Download cleaner config
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
Categories = [],
UnlinkedEnabled = false,
UnlinkedTargetCategory = "",
UnlinkedCategories = []
});
context.SaveChanges();
}
/// <summary>
/// Adds an enabled Sonarr instance to the context
/// </summary>
public static ArrInstance AddSonarrInstance(DataContext context, string url = "http://sonarr:8989", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Sonarr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled Radarr instance to the context
/// </summary>
public static ArrInstance AddRadarrInstance(DataContext context, string url = "http://radarr:7878", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Radarr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled Lidarr instance to the context
/// </summary>
public static ArrInstance AddLidarrInstance(DataContext context, string url = "http://lidarr:8686", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Lidarr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled Readarr instance to the context
/// </summary>
public static ArrInstance AddReadarrInstance(DataContext context, string url = "http://readarr:8787", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Readarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Readarr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled Whisparr instance to the context
/// </summary>
public static ArrInstance AddWhisparrInstance(DataContext context, string url = "http://whisparr:6969", bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Whisparr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Whisparr",
Url = new Uri(url),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
/// <summary>
/// Adds an enabled download client to the context
/// </summary>
public static DownloadClientConfig AddDownloadClient(
DataContext context,
string name = "Test qBittorrent",
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
bool enabled = true)
{
var config = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = name,
TypeName = typeName,
Type = DownloadClientType.Torrent,
Enabled = enabled,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "admin"
};
context.DownloadClients.Add(config);
context.SaveChanges();
return config;
}
/// <summary>
/// Adds a stall rule to the context
/// </summary>
public static StallRule AddStallRule(
DataContext context,
string name = "Test Stall Rule",
bool enabled = true,
ushort minCompletionPercentage = 0,
ushort maxCompletionPercentage = 100,
int maxStrikes = 3)
{
var queueCleanerConfig = context.QueueCleanerConfigs.First();
var rule = new StallRule
{
Id = Guid.NewGuid(),
Name = name,
Enabled = enabled,
MinCompletionPercentage = minCompletionPercentage,
MaxCompletionPercentage = maxCompletionPercentage,
MaxStrikes = maxStrikes,
QueueCleanerConfigId = queueCleanerConfig.Id
};
context.StallRules.Add(rule);
context.SaveChanges();
return rule;
}
/// <summary>
/// Adds a slow rule to the context
/// </summary>
public static SlowRule AddSlowRule(
DataContext context,
string name = "Test Slow Rule",
bool enabled = true,
ushort minCompletionPercentage = 0,
ushort maxCompletionPercentage = 100,
int maxStrikes = 3,
string minSpeed = "1 KB/s")
{
var queueCleanerConfig = context.QueueCleanerConfigs.First();
var rule = new SlowRule
{
Id = Guid.NewGuid(),
Name = name,
Enabled = enabled,
MinCompletionPercentage = minCompletionPercentage,
MaxCompletionPercentage = maxCompletionPercentage,
MaxStrikes = maxStrikes,
MinSpeed = minSpeed,
QueueCleanerConfigId = queueCleanerConfig.Id
};
context.SlowRules.Add(rule);
context.SaveChanges();
return rule;
}
/// <summary>
/// Adds a clean category to the download cleaner config
/// </summary>
public static SeedingRule AddSeedingRule(
DataContext context,
string name = "completed",
double maxRatio = 1.0,
double minSeedTime = 1.0,
double maxSeedTime = -1)
{
var config = context.DownloadCleanerConfigs.Include(x => x.Categories).First();
var category = new SeedingRule
{
Id = Guid.NewGuid(),
Name = name,
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
DeleteSourceFiles = true,
DownloadCleanerConfigId = config.Id
};
config.Categories.Add(category);
context.SeedingRules.Add(category);
context.SaveChanges();
return category;
}
}

View File

@@ -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");
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,203 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class AppriseProviderTests
{
private readonly Mock<IAppriseProxy> _proxyMock;
private readonly AppriseConfig _config;
private readonly AppriseProvider _provider;
public AppriseProviderTests()
{
_proxyMock = new Mock<IAppriseProxy>();
_config = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://apprise.example.com",
Key = "testkey",
Tags = "tag1,tag2"
};
_provider = new AppriseProvider(
"TestApprise",
NotificationProviderType.Apprise,
_config,
_proxyMock.Object);
}
#region Constructor Tests
[Fact]
public void Constructor_SetsNameCorrectly()
{
// Assert
Assert.Equal("TestApprise", _provider.Name);
}
[Fact]
public void Constructor_SetsTypeCorrectly()
{
// Assert
Assert.Equal(NotificationProviderType.Apprise, _provider.Type);
}
#endregion
#region SendNotificationAsync Tests
[Fact]
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
{
// Arrange
var context = CreateTestContext();
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal(context.Title, capturedPayload.Title);
Assert.Contains(context.Description, capturedPayload.Body);
}
[Fact]
public async Task SendNotificationAsync_IncludesDataInBody()
{
// Arrange
var context = CreateTestContext();
context.Data["TestKey"] = "TestValue";
context.Data["AnotherKey"] = "AnotherValue";
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("TestKey: TestValue", capturedPayload.Body);
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Body);
}
[Theory]
[InlineData(EventSeverity.Information, "info")]
[InlineData(EventSeverity.Warning, "warning")]
[InlineData(EventSeverity.Important, "failure")]
public async Task SendNotificationAsync_MapsEventSeverityToCorrectType(EventSeverity severity, string expectedType)
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Notification",
Description = "Test Description",
Severity = severity,
Data = new Dictionary<string, string>()
};
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal(expectedType, capturedPayload.Type);
}
[Fact]
public async Task SendNotificationAsync_IncludesTagsFromConfig()
{
// Arrange
var context = CreateTestContext();
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("tag1,tag2", capturedPayload.Tags);
}
[Fact]
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
{
// Arrange
var context = CreateTestContext();
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.ThrowsAsync(new Exception("Proxy error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
}
[Fact]
public async Task SendNotificationAsync_WithEmptyData_StillIncludesDescription()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = "Test Title",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
ApprisePayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("Test Description", capturedPayload.Body);
}
#endregion
#region Helper Methods
private static NotificationContext CreateTestContext()
{
return new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Notification",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
}
#endregion
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,525 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationConfigurationServiceTests : IDisposable
{
private readonly DataContext _context;
private readonly Mock<ILogger<NotificationConfigurationService>> _loggerMock;
private readonly NotificationConfigurationService _service;
public NotificationConfigurationServiceTests()
{
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DataContext(options);
_loggerMock = new Mock<ILogger<NotificationConfigurationService>>();
_service = new NotificationConfigurationService(_context, _loggerMock.Object);
}
public void Dispose()
{
_context.Database.EnsureDeleted();
_context.Dispose();
}
#region GetActiveProvidersAsync Tests
[Fact]
public async Task GetActiveProvidersAsync_NoProviders_ReturnsEmptyList()
{
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetActiveProvidersAsync_WithEnabledProvider_ReturnsProvider()
{
// Arrange
var config = CreateNotifiarrConfig("Test Provider", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Single(result);
Assert.Equal("Test Provider", result[0].Name);
}
[Fact]
public async Task GetActiveProvidersAsync_WithDisabledProvider_ReturnsEmptyList()
{
// Arrange
var config = CreateNotifiarrConfig("Disabled Provider", isEnabled: false);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetActiveProvidersAsync_CachesResults()
{
// Arrange
var config = CreateNotifiarrConfig("Test Provider", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act - Call twice
var result1 = await _service.GetActiveProvidersAsync();
var result2 = await _service.GetActiveProvidersAsync();
// Assert - Both calls should return same data
Assert.Single(result1);
Assert.Single(result2);
Assert.Equal(result1[0].Id, result2[0].Id);
}
[Fact]
public async Task GetActiveProvidersAsync_WithMixedProviders_ReturnsOnlyEnabled()
{
// Arrange
var enabledConfig = CreateNotifiarrConfig("Enabled", isEnabled: true);
var disabledConfig = CreateNotifiarrConfig("Disabled", isEnabled: false);
_context.Set<NotificationConfig>().AddRange(enabledConfig, disabledConfig);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Single(result);
Assert.Equal("Enabled", result[0].Name);
}
#endregion
#region GetProvidersForEventAsync Tests
[Fact]
public async Task GetProvidersForEventAsync_NoMatchingProviders_ReturnsEmptyList()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: false);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetProvidersForEventAsync_WithMatchingProvider_ReturnsProvider()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
// Assert
Assert.Single(result);
}
[Fact]
public async Task GetProvidersForEventAsync_TestEvent_AlwaysReturnsEnabledProviders()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: false);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(NotificationEventType.Test);
// Assert
Assert.Single(result);
}
[Theory]
[InlineData(NotificationEventType.FailedImportStrike, true, false, false, false, false, false)]
[InlineData(NotificationEventType.StalledStrike, false, true, false, false, false, false)]
[InlineData(NotificationEventType.SlowSpeedStrike, false, false, true, false, false, false)]
[InlineData(NotificationEventType.SlowTimeStrike, false, false, true, false, false, false)]
[InlineData(NotificationEventType.QueueItemDeleted, false, false, false, true, false, false)]
[InlineData(NotificationEventType.DownloadCleaned, false, false, false, false, true, false)]
[InlineData(NotificationEventType.CategoryChanged, false, false, false, false, false, true)]
public async Task GetProvidersForEventAsync_ReturnsProviderForCorrectEvents(
NotificationEventType eventType,
bool onFailedImport, bool onStalled, bool onSlow,
bool onDeleted, bool onCleaned, bool onCategory)
{
// Arrange
var config = new NotificationConfig
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
OnFailedImportStrike = onFailedImport,
OnStalledStrike = onStalled,
OnSlowStrike = onSlow,
OnQueueItemDeleted = onDeleted,
OnDownloadCleaned = onCleaned,
OnCategoryChanged = onCategory,
NotifiarrConfiguration = new NotifiarrConfig
{
Id = Guid.NewGuid(),
ApiKey = "testapikey1234567890",
ChannelId = "123456789012345678"
}
};
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(eventType);
// Assert
Assert.Single(result);
}
#endregion
#region GetProviderByIdAsync Tests
[Fact]
public async Task GetProviderByIdAsync_ProviderExists_ReturnsProvider()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProviderByIdAsync(config.Id);
// Assert
Assert.NotNull(result);
Assert.Equal(config.Id, result.Id);
Assert.Equal("Test", result.Name);
}
[Fact]
public async Task GetProviderByIdAsync_ProviderDoesNotExist_ReturnsNull()
{
// Act
var result = await _service.GetProviderByIdAsync(Guid.NewGuid());
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetProviderByIdAsync_DisabledProvider_ReturnsNull()
{
// Arrange
var config = CreateNotifiarrConfig("Disabled", isEnabled: false);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProviderByIdAsync(config.Id);
// Assert
Assert.Null(result);
}
#endregion
#region InvalidateCacheAsync Tests
[Fact]
public async Task InvalidateCacheAsync_RefreshesDataOnNextCall()
{
// Arrange
var config1 = CreateNotifiarrConfig("Provider 1", isEnabled: true);
_context.Set<NotificationConfig>().Add(config1);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// First call to populate cache
var result1 = await _service.GetActiveProvidersAsync();
Assert.Single(result1);
// Add another provider
var config2 = CreateNotifiarrConfig("Provider 2", isEnabled: true);
_context.Set<NotificationConfig>().Add(config2);
await _context.SaveChangesAsync();
// Without invalidation, should return cached result
var result2 = await _service.GetActiveProvidersAsync();
Assert.Single(result2);
// After invalidation, should return updated result
await _service.InvalidateCacheAsync();
var result3 = await _service.GetActiveProvidersAsync();
Assert.Equal(2, result3.Count);
}
[Fact]
public async Task InvalidateCacheAsync_LogsDebugMessage()
{
// Act
await _service.InvalidateCacheAsync();
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("cache invalidated")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task GetProvidersForEventAsync_UnknownEventType_ThrowsArgumentOutOfRangeException()
{
// Arrange
var config = CreateNotifiarrConfig("Test", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
var unknownEventType = (NotificationEventType)999;
// Act & Assert
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => _service.GetProvidersForEventAsync(unknownEventType));
}
[Fact]
public async Task GetActiveProvidersAsync_DatabaseError_ReturnsEmptyListAndLogsError()
{
// Arrange - dispose context to simulate database error
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var disposedContext = new DataContext(options);
var loggerMock = new Mock<ILogger<NotificationConfigurationService>>();
var service = new NotificationConfigurationService(disposedContext, loggerMock.Object);
await disposedContext.DisposeAsync();
// Act
var result = await service.GetActiveProvidersAsync();
// Assert
Assert.Empty(result);
loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to load notification providers")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region Provider Type Mapping Tests
[Theory]
[InlineData(NotificationProviderType.Notifiarr)]
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
{
// Arrange
var config = CreateConfigForType(providerType, "Test Provider", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetActiveProvidersAsync();
// Assert
Assert.Single(result);
Assert.Equal(providerType, result[0].Type);
Assert.Equal("Test Provider", result[0].Name);
Assert.NotNull(result[0].Configuration);
}
[Theory]
[InlineData(NotificationProviderType.Notifiarr)]
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
{
// Arrange
var config = CreateConfigForType(providerType, "Test", isEnabled: true);
_context.Set<NotificationConfig>().Add(config);
await _context.SaveChangesAsync();
await _service.InvalidateCacheAsync();
// Act
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
// Assert
Assert.Single(result);
Assert.Equal(providerType, result[0].Type);
}
#endregion
#region Helper Methods
private static NotificationConfig CreateConfigForType(
NotificationProviderType providerType,
string name,
bool isEnabled)
{
return providerType switch
{
NotificationProviderType.Notifiarr => CreateNotifiarrConfig(name, isEnabled),
NotificationProviderType.Apprise => CreateAppriseConfig(name, isEnabled),
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
};
}
private static NotificationConfig CreateNotifiarrConfig(
string name,
bool isEnabled,
bool onStalledStrike = true,
bool onFailedImport = true,
bool onSlow = true,
bool onDeleted = true,
bool onCleaned = true,
bool onCategory = true)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Notifiarr,
IsEnabled = isEnabled,
OnStalledStrike = onStalledStrike,
OnFailedImportStrike = onFailedImport,
OnSlowStrike = onSlow,
OnQueueItemDeleted = onDeleted,
OnDownloadCleaned = onCleaned,
OnCategoryChanged = onCategory,
NotifiarrConfiguration = new NotifiarrConfig
{
Id = Guid.NewGuid(),
ApiKey = "testapikey1234567890",
ChannelId = "123456789012345678"
}
};
}
private static NotificationConfig CreateAppriseConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Apprise,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
AppriseConfiguration = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://localhost:8000",
Key = "testkey"
}
};
}
private static NotificationConfig CreateNtfyConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Ntfy,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
NtfyConfiguration = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "https://ntfy.sh",
Topics = ["test-topic"]
}
};
}
private static NotificationConfig CreatePushoverConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Pushover,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
PushoverConfiguration = new PushoverConfig
{
Id = Guid.NewGuid(),
ApiToken = "test_api_token_1234567890abcd",
UserKey = "test_user_key_1234567890abcde"
}
};
}
#endregion
}

View File

@@ -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
}

View File

@@ -0,0 +1,294 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationProviderFactoryTests
{
private readonly Mock<IAppriseProxy> _appriseProxyMock;
private readonly Mock<INtfyProxy> _ntfyProxyMock;
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
private readonly IServiceProvider _serviceProvider;
private readonly NotificationProviderFactory _factory;
public NotificationProviderFactoryTests()
{
_appriseProxyMock = new Mock<IAppriseProxy>();
_ntfyProxyMock = new Mock<INtfyProxy>();
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
_pushoverProxyMock = new Mock<IPushoverProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
services.AddSingleton(_ntfyProxyMock.Object);
services.AddSingleton(_notifiarrProxyMock.Object);
services.AddSingleton(_pushoverProxyMock.Object);
_serviceProvider = services.BuildServiceProvider();
_factory = new NotificationProviderFactory(_serviceProvider);
}
#region CreateProvider Tests
[Fact]
public void CreateProvider_AppriseType_CreatesAppriseProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestApprise",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Configuration = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://apprise.example.com",
Key = "testkey"
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<AppriseProvider>(provider);
Assert.Equal("TestApprise", provider.Name);
Assert.Equal(NotificationProviderType.Apprise, provider.Type);
}
[Fact]
public void CreateProvider_NtfyType_CreatesNtfyProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestNtfy",
Type = NotificationProviderType.Ntfy,
IsEnabled = true,
Configuration = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { "test-topic" },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.Default
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<NtfyProvider>(provider);
Assert.Equal("TestNtfy", provider.Name);
Assert.Equal(NotificationProviderType.Ntfy, provider.Type);
}
[Fact]
public void CreateProvider_NotifiarrType_CreatesNotifiarrProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestNotifiarr",
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
Configuration = new NotifiarrConfig
{
Id = Guid.NewGuid(),
ApiKey = "testapikey1234567890",
ChannelId = "123456789012345678"
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<NotifiarrProvider>(provider);
Assert.Equal("TestNotifiarr", provider.Name);
Assert.Equal(NotificationProviderType.Notifiarr, provider.Type);
}
[Fact]
public void CreateProvider_PushoverType_CreatesPushoverProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestPushover",
Type = NotificationProviderType.Pushover,
IsEnabled = true,
Configuration = new PushoverConfig
{
Id = Guid.NewGuid(),
ApiToken = "test-api-token",
UserKey = "test-user-key",
Devices = new List<string>(),
Priority = PushoverPriority.Normal,
Sound = "",
Tags = new List<string>()
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<PushoverProvider>(provider);
Assert.Equal("TestPushover", provider.Name);
Assert.Equal(NotificationProviderType.Pushover, provider.Type);
}
[Fact]
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestUnsupported",
Type = (NotificationProviderType)999, // Invalid type
IsEnabled = true,
Configuration = new object()
};
// Act & Assert
var exception = Assert.Throws<NotSupportedException>(() => _factory.CreateProvider(config));
Assert.Contains("not supported", exception.Message);
}
[Fact]
public void CreateProvider_AppriseType_UsesCorrectProxy()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestApprise",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Configuration = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://apprise.example.com",
Key = "testkey"
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert - provider was created with the injected proxy
Assert.NotNull(provider);
// The proxy would be used when SendNotificationAsync is called
}
[Fact]
public void CreateProvider_PreservesProviderName()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "My Custom Provider Name",
Type = NotificationProviderType.Ntfy,
IsEnabled = true,
Configuration = new NtfyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://ntfy.example.com",
Topics = new List<string> { "test" },
AuthenticationType = NtfyAuthenticationType.None,
Priority = NtfyPriority.Default
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.Equal("My Custom Provider Name", provider.Name);
}
[Fact]
public void CreateProvider_PreservesProviderType()
{
// Arrange
var configs = new[]
{
(Type: NotificationProviderType.Apprise, Config: (object)new AppriseConfig { Id = Guid.NewGuid(), Url = "http://test.com", Key = "key" }),
(Type: NotificationProviderType.Ntfy, Config: (object)new NtfyConfig { Id = Guid.NewGuid(), ServerUrl = "http://test.com", Topics = new List<string> { "t" }, AuthenticationType = NtfyAuthenticationType.None, Priority = NtfyPriority.Default }),
(Type: NotificationProviderType.Notifiarr, Config: (object)new NotifiarrConfig { Id = Guid.NewGuid(), ApiKey = "1234567890", ChannelId = "12345" }),
(Type: NotificationProviderType.Pushover, Config: (object)new PushoverConfig { Id = Guid.NewGuid(), ApiToken = "token", UserKey = "user", Devices = new List<string>(), Priority = PushoverPriority.Normal, Sound = "", Tags = new List<string>() })
};
foreach (var (type, configObj) in configs)
{
var dto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = $"Test-{type}",
Type = type,
IsEnabled = true,
Configuration = configObj
};
// Act
var provider = _factory.CreateProvider(dto);
// Assert
Assert.Equal(type, provider.Type);
}
}
#endregion
#region Service Resolution Tests
[Fact]
public void CreateProvider_WhenProxyNotRegistered_ThrowsException()
{
// Arrange - create a service provider without the proxy
var emptyServices = new ServiceCollection();
var emptyServiceProvider = emptyServices.BuildServiceProvider();
var factoryWithNoServices = new NotificationProviderFactory(emptyServiceProvider);
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestApprise",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Configuration = new AppriseConfig
{
Id = Guid.NewGuid(),
Url = "http://test.com",
Key = "key"
}
};
// Act & Assert
Assert.Throws<InvalidOperationException>(() => factoryWithNoServices.CreateProvider(config));
}
#endregion
}

View File

@@ -0,0 +1,597 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationPublisherTests
{
private readonly Mock<ILogger<NotificationPublisher>> _loggerMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<INotificationConfigurationService> _configServiceMock;
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
private readonly NotificationPublisher _publisher;
public NotificationPublisherTests()
{
_loggerMock = new Mock<ILogger<NotificationPublisher>>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_configServiceMock = new Mock<INotificationConfigurationService>();
_providerFactoryMock = new Mock<INotificationProviderFactory>();
// Setup dry run interceptor to call through
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns<Delegate, object[]>(async (action, parameters) =>
{
if (action is Func<(NotificationEventType, NotificationContext), Task> func && parameters.Length > 0)
{
var param = ((NotificationEventType, NotificationContext))parameters[0];
await func(param);
}
});
_publisher = new NotificationPublisher(
_loggerMock.Object,
_dryRunInterceptorMock.Object,
_configServiceMock.Object,
_providerFactoryMock.Object);
}
private void SetupContext(InstanceType instanceType = InstanceType.Sonarr)
{
var record = new QueueRecord
{
Id = 1,
Title = "Test Show",
DownloadId = "ABCD1234",
Status = "Downloading",
Protocol = "torrent"
};
ContextProvider.Set(nameof(QueueRecord), record);
ContextProvider.Set(nameof(InstanceType), (object)instanceType);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://sonarr.local"));
}
private void SetupDownloadCleanerContext()
{
ContextProvider.Set("downloadName", "Test Download");
ContextProvider.Set("hash", "HASH123");
}
#region Constructor Tests
[Fact]
public void Constructor_SetsAllDependencies()
{
// Assert
Assert.NotNull(_publisher);
}
#endregion
#region NotifyStrike Tests
[Fact]
public async Task NotifyStrike_WithStalledStrike_SendsNotification()
{
// Arrange
SetupContext();
var rule = new StallRule { Name = "Test Rule" };
ContextProvider.Set<QueueRule>(rule);
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.StalledStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyStrike(StrikeType.Stalled, 1);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.StalledStrike &&
c.Data.ContainsKey("Strike type") &&
c.Data["Strike type"] == "Stalled")), Times.Once);
}
[Fact]
public async Task NotifyStrike_WithFailedImportStrike_MapsToCorrectEventType()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 2);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.FailedImportStrike &&
c.Data["Strike count"] == "2")), Times.Once);
}
[Theory]
[InlineData(StrikeType.Stalled, NotificationEventType.StalledStrike)]
[InlineData(StrikeType.DownloadingMetadata, NotificationEventType.StalledStrike)]
[InlineData(StrikeType.FailedImport, NotificationEventType.FailedImportStrike)]
[InlineData(StrikeType.SlowSpeed, NotificationEventType.SlowSpeedStrike)]
[InlineData(StrikeType.SlowTime, NotificationEventType.SlowTimeStrike)]
public async Task NotifyStrike_MapsStrikeTypeToCorrectEventType(StrikeType strikeType, NotificationEventType expectedEventType)
{
// Arrange
SetupContext();
if (strikeType is StrikeType.Stalled or StrikeType.SlowSpeed or StrikeType.SlowTime)
{
var rule = new StallRule { Name = "Test Rule" };
ContextProvider.Set<QueueRule>(rule);
}
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(expectedEventType))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyStrike(strikeType, 1);
// Assert
_configServiceMock.Verify(c => c.GetProvidersForEventAsync(expectedEventType), Times.Once);
}
[Fact]
public async Task NotifyStrike_WhenNoProviders_DoesNotThrow()
{
// Arrange
SetupContext();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto>());
// Act & Assert - Should not throw
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
}
[Fact]
public async Task NotifyStrike_WhenProviderThrows_LogsWarningAndContinues()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.ThrowsAsync(new Exception("Provider failed"));
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act - Should not throw
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region NotifyQueueItemDeleted Tests
[Fact]
public async Task NotifyQueueItemDeleted_SendsNotificationWithCorrectContext()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.QueueItemDeleted &&
c.Data["Reason"] == "Stalled" &&
c.Data["Removed from client?"] == "True" &&
c.Severity == EventSeverity.Important)), Times.Once);
}
[Fact]
public async Task NotifyQueueItemDeleted_WhenRemoveFromClientFalse_ReflectsInContext()
{
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.MalwareFileFound);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.Data["Removed from client?"] == "False" &&
c.Data["Reason"] == "MalwareFileFound")), Times.Once);
}
#endregion
#region NotifyDownloadCleaned Tests
[Fact]
public async Task NotifyDownloadCleaned_SendsNotificationWithCorrectContext()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyDownloadCleaned(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.DownloadCleaned &&
c.Description == "Test Download" &&
c.Data["Category"] == "movies" &&
c.Data["Ratio"] == "2.5" &&
c.Data["Seeding hours"] == "48")), Times.Once);
}
[Fact]
public async Task NotifyDownloadCleaned_WithSeedingTime_RoundsToWholeHours()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
NotificationContext? capturedContext = null;
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.Callback<NotificationContext>(c => capturedContext = c)
.Returns(Task.CompletedTask);
// Act
await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(24.7), "tv", CleanReason.MaxSeedTimeReached);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal("25", capturedContext.Data["Seeding hours"]); // Rounds to 25
}
#endregion
#region NotifyCategoryChanged Tests
[Fact]
public async Task NotifyCategoryChanged_WhenNotTag_IncludesOldAndNewCategory()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyCategoryChanged("tv-sonarr", "seeding", false);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.EventType == NotificationEventType.CategoryChanged &&
c.Title == "Category changed" &&
c.Data["Old category"] == "tv-sonarr" &&
c.Data["New category"] == "seeding")), Times.Once);
}
[Fact]
public async Task NotifyCategoryChanged_WhenIsTag_IncludesOnlyTag()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
NotificationContext? capturedContext = null;
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.Callback<NotificationContext>(c => capturedContext = c)
.Returns(Task.CompletedTask);
// Act
await _publisher.NotifyCategoryChanged("", "seeded", true);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal("Tag added", capturedContext.Title);
Assert.True(capturedContext.Data.ContainsKey("Tag"));
Assert.Equal("seeded", capturedContext.Data["Tag"]);
Assert.False(capturedContext.Data.ContainsKey("Old category"));
Assert.False(capturedContext.Data.ContainsKey("New category"));
}
[Fact]
public async Task NotifyCategoryChanged_SetsSeverityToInformation()
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var providerMock = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
.Returns(providerMock.Object);
// Act
await _publisher.NotifyCategoryChanged("old", "new", false);
// Assert
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
c => c.Severity == EventSeverity.Information)), Times.Once);
}
#endregion
#region SendNotificationAsync Tests (through notify methods)
[Fact]
public async Task SendNotificationAsync_WhenMultipleProviders_SendsToAll()
{
// Arrange
SetupContext();
var providerDto1 = CreateProviderDto("Provider1");
var providerDto2 = CreateProviderDto("Provider2");
var providerMock1 = new Mock<INotificationProvider>();
var providerMock2 = new Mock<INotificationProvider>();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto1, providerDto2 });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto1))
.Returns(providerMock1.Object);
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto2))
.Returns(providerMock2.Object);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
providerMock1.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
}
[Fact]
public async Task SendNotificationAsync_WhenOneProviderFails_OthersStillSend()
{
// Arrange
SetupContext();
var providerDto1 = CreateProviderDto("Provider1");
var providerDto2 = CreateProviderDto("Provider2");
var providerMock1 = new Mock<INotificationProvider>();
var providerMock2 = new Mock<INotificationProvider>();
providerMock1.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
.ThrowsAsync(new Exception("Failed"));
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
.ReturnsAsync(new List<NotificationProviderDto> { providerDto1, providerDto2 });
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto1))
.Returns(providerMock1.Object);
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto2))
.Returns(providerMock2.Object);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert - Provider2 should still be called
providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
}
[Fact]
public async Task SendNotificationAsync_UsesDryRunInterceptor()
{
// Arrange
SetupContext();
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
.ReturnsAsync(new List<NotificationProviderDto>());
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
It.IsAny<Func<(NotificationEventType, NotificationContext), Task>>(),
It.IsAny<(NotificationEventType, NotificationContext)>()), Times.Once);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task NotifyStrike_WhenExceptionOccurs_LogsError()
{
// Arrange
// Setup dry run interceptor to throw when called
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.ThrowsAsync(new Exception("Interceptor failed"));
SetupContext();
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to notify strike")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task NotifyQueueItemDeleted_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.ThrowsAsync(new Exception("Error"));
SetupContext();
// Act
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify queue item deleted")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task NotifyDownloadCleaned_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.ThrowsAsync(new Exception("Error"));
SetupDownloadCleanerContext();
// Act
await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(1), "test", CleanReason.MaxRatioReached);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify download cleaned")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task NotifyCategoryChanged_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.ThrowsAsync(new Exception("Error"));
SetupDownloadCleanerContext();
// Act
await _publisher.NotifyCategoryChanged("old", "new", false);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify category changed")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region Helper Methods
private static NotificationProviderDto CreateProviderDto(string name = "TestProvider")
{
return new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true
},
Configuration = new { ApiKey = "test", ChannelId = "123" }
};
}
#endregion
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -399,13 +399,12 @@ public class QueueRuleMatchTests
Assert.False(rule.MatchesTorrent(publicTorrent.Object));
}
private static Mock<ITorrentItem> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
private static Mock<ITorrentItemWrapper> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
{
var torrent = new Mock<ITorrentItem>();
var torrent = new Mock<ITorrentItemWrapper>();
torrent.SetupGet(t => t.IsPrivate).Returns(isPrivate);
torrent.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
torrent.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
torrent.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
return torrent;
}
}

View File

@@ -0,0 +1,98 @@
using Cleanuparr.Infrastructure.Health;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Xunit;
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
namespace Cleanuparr.Infrastructure.Tests.Health;
public class ApplicationHealthCheckTests
{
#region Constructor Tests
[Fact]
public void Constructor_CreatesInstance()
{
// Act
var healthCheck = new ApplicationHealthCheck();
// Assert
Assert.NotNull(healthCheck);
}
#endregion
#region CheckHealthAsync Tests
[Fact]
public async Task CheckHealthAsync_ReturnsHealthy()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_DescriptionIndicatesRunning()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Contains("running", result.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CheckHealthAsync_WithCancellationToken_CompletesSuccessfully()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
using var cts = new CancellationTokenSource();
// Act
var result = await healthCheck.CheckHealthAsync(null!, cts.Token);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_WithContext_CompletesSuccessfully()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
var context = new HealthCheckContext();
// Act
var result = await healthCheck.CheckHealthAsync(context);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_MultipleCalls_AllReturnHealthy()
{
// Arrange
var healthCheck = new ApplicationHealthCheck();
// Act
var result1 = await healthCheck.CheckHealthAsync(null!);
var result2 = await healthCheck.CheckHealthAsync(null!);
var result3 = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result1.Status);
Assert.Equal(HealthCheckStatus.Healthy, result2.Status);
Assert.Equal(HealthCheckStatus.Healthy, result3.Status);
}
#endregion
}

View File

@@ -0,0 +1,122 @@
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
namespace Cleanuparr.Infrastructure.Tests.Health;
/// <summary>
/// Basic tests for DatabaseHealthCheck.
/// Note: Full integration testing requires a real database since in-memory provider
/// doesn't support migrations (GetPendingMigrationsAsync).
/// </summary>
public class DatabaseHealthCheckTests : IDisposable
{
private readonly Mock<ILogger<DatabaseHealthCheck>> _loggerMock;
private DataContext? _dataContext;
public DatabaseHealthCheckTests()
{
_loggerMock = new Mock<ILogger<DatabaseHealthCheck>>();
}
public void Dispose()
{
_dataContext?.Dispose();
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Arrange
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_dataContext = new DataContext(options);
// Act
var healthCheck = new DatabaseHealthCheck(_dataContext, _loggerMock.Object);
// Assert
Assert.NotNull(healthCheck);
}
#endregion
#region Exception Handling Tests
[Fact]
public async Task CheckHealthAsync_WhenDisposedContext_ReturnsUnhealthy()
{
// Arrange
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var disposedContext = new DataContext(options);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_WhenUnhealthy_LogsError()
{
// Arrange
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var disposedContext = new DataContext(options);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
// Act
await healthCheck.CheckHealthAsync(null!);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task CheckHealthAsync_WhenUnhealthy_DescriptionIndicatesFailure()
{
// Arrange
var options = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var disposedContext = new DataContext(options);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Contains("failed", result.Description, StringComparison.OrdinalIgnoreCase);
}
#endregion
}

View File

@@ -0,0 +1,242 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Health;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
using HealthStatus = Cleanuparr.Infrastructure.Health.HealthStatus;
namespace Cleanuparr.Infrastructure.Tests.Health;
public class DownloadClientsHealthCheckTests
{
private readonly Mock<IHealthCheckService> _healthCheckServiceMock;
private readonly Mock<ILogger<DownloadClientsHealthCheck>> _loggerMock;
private readonly DownloadClientsHealthCheck _healthCheck;
public DownloadClientsHealthCheckTests()
{
_healthCheckServiceMock = new Mock<IHealthCheckService>();
_loggerMock = new Mock<ILogger<DownloadClientsHealthCheck>>();
_healthCheck = new DownloadClientsHealthCheck(_healthCheckServiceMock.Object, _loggerMock.Object);
}
#region CheckHealthAsync Tests
[Fact]
public async Task CheckHealthAsync_WhenNoClientsConfigured_ReturnsHealthy()
{
// Arrange
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(new Dictionary<Guid, HealthStatus>());
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
Assert.Contains("No download clients configured", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WhenAllClientsHealthy_ReturnsHealthy()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateHealthyStatus("Client2") },
{ Guid.NewGuid(), CreateHealthyStatus("Client3") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
Assert.Contains("All 3 download clients are healthy", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WhenSomeClientsUnhealthy_ReturnsDegraded()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateHealthyStatus("Client2") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client3") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Degraded, result.Status);
Assert.Contains("1/3", result.Description);
Assert.Contains("Client3", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WhenMajorityUnhealthy_ReturnsUnhealthy()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client3") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
Assert.Contains("2/3", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WhenAllUnhealthy_ReturnsUnhealthy()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateUnhealthyStatus("Client1") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_WhenServiceThrows_ReturnsUnhealthy()
{
// Arrange
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Throws(new Exception("Service error"));
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
Assert.Contains("Download clients health check failed", result.Description);
}
[Fact]
public async Task CheckHealthAsync_IncludesUnhealthyClientNames()
{
// Arrange
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("HealthyClient") },
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient1") },
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient2") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Contains("BrokenClient1", result.Description);
Assert.Contains("BrokenClient2", result.Description);
}
[Fact]
public async Task CheckHealthAsync_WithSingleClient_HandlesCorrectly()
{
// Arrange - Single healthy client
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("OnlyClient") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_WithSingleUnhealthyClient_ReturnsUnhealthy()
{
// Arrange - Single unhealthy client (1/1 > 50%)
var clients = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient") }
};
_healthCheckServiceMock
.Setup(s => s.GetAllClientHealth())
.Returns(clients);
// Act
var result = await _healthCheck.CheckHealthAsync(null!);
// Assert
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
}
#endregion
#region Helper Methods
private static HealthStatus CreateHealthyStatus(string clientName)
{
return new HealthStatus
{
IsHealthy = true,
ClientName = clientName,
ClientId = Guid.NewGuid(),
LastChecked = DateTime.UtcNow,
ClientTypeName = DownloadClientTypeName.qBittorrent
};
}
private static HealthStatus CreateUnhealthyStatus(string clientName)
{
return new HealthStatus
{
IsHealthy = false,
ClientName = clientName,
ClientId = Guid.NewGuid(),
LastChecked = DateTime.UtcNow,
ErrorMessage = "Connection failed",
ClientTypeName = DownloadClientTypeName.qBittorrent
};
}
#endregion
}

View File

@@ -0,0 +1,345 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Health;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Health;
public class HealthCheckBackgroundServiceTests : IDisposable
{
private readonly Mock<ILogger<HealthCheckBackgroundService>> _loggerMock;
private readonly Mock<IHealthCheckService> _healthCheckServiceMock;
private HealthCheckBackgroundService? _service;
public HealthCheckBackgroundServiceTests()
{
_loggerMock = new Mock<ILogger<HealthCheckBackgroundService>>();
_healthCheckServiceMock = new Mock<IHealthCheckService>();
}
public void Dispose()
{
_service?.Dispose();
}
private HealthCheckBackgroundService CreateService()
{
_service = new HealthCheckBackgroundService(
_loggerMock.Object,
_healthCheckServiceMock.Object);
return _service;
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var service = CreateService();
// Assert
Assert.NotNull(service);
}
#endregion
#region ExecuteAsync Tests
[Fact]
public async Task ExecuteAsync_WhenCancelledImmediately_StopsGracefully()
{
// Arrange
var service = CreateService();
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act
await service.StartAsync(cts.Token);
await service.StopAsync(CancellationToken.None);
// Assert - Should not throw
}
[Fact]
public async Task ExecuteAsync_CallsCheckAllClientsHealthAsync()
{
// Arrange
var service = CreateService();
var healthResults = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") }
};
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
// Give it some time to execute at least once
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert
_healthCheckServiceMock.Verify(s => s.CheckAllClientsHealthAsync(), Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WhenAllClientsHealthy_LogsDebugMessage()
{
// Arrange
var service = CreateService();
var healthResults = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateHealthyStatus("Client2") }
};
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Check that debug log was called (all healthy)
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("healthy")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WhenSomeClientsUnhealthy_LogsWarningMessage()
{
// Arrange
var service = CreateService();
var healthResults = new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2", "Connection failed") }
};
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Check that warning log was called for unhealthy clients
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unhealthy")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WhenHealthCheckThrows_LogsErrorAndContinues()
{
// Arrange
var service = CreateService();
var callCount = 0;
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
throw new Exception("Health check failed");
}
return new Dictionary<Guid, HealthStatus>
{
{ Guid.NewGuid(), CreateHealthyStatus("Client1") }
};
});
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Error should be logged
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error performing periodic health check")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WithNoClients_HandlesEmptyResults()
{
// Arrange
var service = CreateService();
var healthResults = new Dictionary<Guid, HealthStatus>();
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Should handle gracefully
_healthCheckServiceMock.Verify(s => s.CheckAllClientsHealthAsync(), Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_LogsDetailedInfoForUnhealthyClients()
{
// Arrange
var service = CreateService();
var unhealthyClientId = Guid.NewGuid();
var healthResults = new Dictionary<Guid, HealthStatus>
{
{ unhealthyClientId, CreateUnhealthyStatus("UnhealthyClient", "Connection timeout") }
};
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(healthResults);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Should log details about the unhealthy client
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("UnhealthyClient") ||
v.ToString()!.Contains("Connection timeout")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
#endregion
#region Lifecycle Tests
[Fact]
public async Task StartAsync_StartsBackgroundService()
{
// Arrange
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(new Dictionary<Guid, HealthStatus>());
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
// Assert
Assert.NotNull(service);
// Cleanup
cts.Cancel();
await service.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopAsync_StopsGracefully()
{
// Arrange
_healthCheckServiceMock
.Setup(s => s.CheckAllClientsHealthAsync())
.ReturnsAsync(new Dictionary<Guid, HealthStatus>());
var service = CreateService();
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
// Act
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert - Should log stop message
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopped")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
#endregion
#region Helper Methods
private static HealthStatus CreateHealthyStatus(string clientName)
{
return new HealthStatus
{
IsHealthy = true,
ClientName = clientName,
ClientId = Guid.NewGuid(),
LastChecked = DateTime.UtcNow,
ResponseTime = TimeSpan.FromMilliseconds(50),
ClientTypeName = DownloadClientTypeName.qBittorrent
};
}
private static HealthStatus CreateUnhealthyStatus(string clientName, string errorMessage)
{
return new HealthStatus
{
IsHealthy = false,
ClientName = clientName,
ClientId = Guid.NewGuid(),
LastChecked = DateTime.UtcNow,
ResponseTime = TimeSpan.Zero,
ErrorMessage = errorMessage,
ClientTypeName = DownloadClientTypeName.qBittorrent
};
}
#endregion
}

View File

@@ -1,91 +0,0 @@
// using Common.Configuration;
// using Common.Enums;
// using Infrastructure.Configuration;
// using Infrastructure.Health;
// using Infrastructure.Verticals.DownloadClient;
// using Infrastructure.Verticals.DownloadClient.Factory;
// using Microsoft.Extensions.Logging;
// using NSubstitute;
// using NSubstitute.ExceptionExtensions;
//
// namespace Infrastructure.Tests.Health;
//
// public class HealthCheckServiceFixture : IDisposable
// {
// public ILogger<HealthCheckService> Logger { get; }
// public IConfigManager ConfigManager { get; }
// public IDownloadClientFactory ClientFactory { get; }
// public IDownloadService MockClient { get; }
// public DownloadClientConfigs DownloadClientConfigs { get; }
//
// public HealthCheckServiceFixture()
// {
// Logger = Substitute.For<ILogger<HealthCheckService>>();
// ConfigManager = Substitute.For<IConfigManager>();
// ClientFactory = Substitute.For<IDownloadClientFactory>();
// MockClient = Substitute.For<IDownloadService>();
// Guid clientId = Guid.NewGuid();
//
// // Set up test download client config
// DownloadClientConfigs = new DownloadClientConfigs
// {
// Clients = new List<DownloadClientConfig>
// {
// new()
// {
// Id = clientId,
// Name = "Test QBittorrent",
// Type = DownloadClientType.QBittorrent,
// Enabled = true,
// Username = "admin",
// Password = "adminadmin"
// },
// new()
// {
// Id = Guid.NewGuid(),
// Name = "Test Transmission",
// Type = DownloadClientType.Transmission,
// Enabled = true,
// Username = "admin",
// Password = "adminadmin"
// },
// new()
// {
// Id = Guid.NewGuid(),
// Name = "Disabled Client",
// Type = DownloadClientType.QBittorrent,
// Enabled = false,
// }
// }
// };
//
// // Set up the mock client factory
// ClientFactory.GetClient(Arg.Any<Guid>()).Returns(MockClient);
// MockClient.GetClientId().Returns(clientId);
//
// // Set up mock config manager
// ConfigManager.GetConfiguration<DownloadClientConfigs>().Returns(DownloadClientConfigs);
// }
//
// public HealthCheckService CreateSut()
// {
// return new HealthCheckService(Logger, ConfigManager, ClientFactory);
// }
//
// public void SetupHealthyClient(Guid clientId)
// {
// // Setup a client that will successfully login
// MockClient.LoginAsync().Returns(Task.CompletedTask);
// }
//
// public void SetupUnhealthyClient(Guid clientId, string errorMessage = "Failed to connect")
// {
// // Setup a client that will fail to login
// MockClient.LoginAsync().Throws(new Exception(errorMessage));
// }
//
// public void Dispose()
// {
// // Cleanup if needed
// }
// }

View File

@@ -1,177 +0,0 @@
// using Infrastructure.Health;
// using NSubstitute;
// using Shouldly;
//
// namespace Infrastructure.Tests.Health;
//
// public class HealthCheckServiceTests : IClassFixture<HealthCheckServiceFixture>
// {
// private readonly HealthCheckServiceFixture _fixture;
//
// public HealthCheckServiceTests(HealthCheckServiceFixture fixture)
// {
// _fixture = fixture;
// }
//
// [Fact]
// public async Task CheckClientHealthAsync_WithHealthyClient_ShouldReturnHealthyStatus()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Act
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.IsHealthy.ShouldBeTrue(),
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001")),
// () => result.ErrorMessage.ShouldBeNull(),
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
// );
// }
//
// [Fact]
// public async Task CheckClientHealthAsync_WithUnhealthyClient_ShouldReturnUnhealthyStatus()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000001"), "Connection refused");
//
// // Act
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.IsHealthy.ShouldBeFalse(),
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001")),
// () => result.ErrorMessage?.ShouldContain("Connection refused"),
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
// );
// }
//
// [Fact]
// public async Task CheckClientHealthAsync_WithNonExistentClient_ShouldReturnErrorStatus()
// {
// // Arrange
// var sut = _fixture.CreateSut();
//
// // Configure the ConfigManager to return null for the client config
// _fixture.ConfigManager.GetConfigurationAsync<DownloadClientConfigs>().Returns(
// Task.FromResult<DownloadClientConfigs>(new())
// );
//
// // Act
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000010"));
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.IsHealthy.ShouldBeFalse(),
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000010")),
// () => result.ErrorMessage?.ShouldContain("not found"),
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
// );
// }
//
// [Fact]
// public async Task CheckAllClientsHealthAsync_ShouldReturnAllEnabledClients()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000002"));
//
// // Act
// var results = await sut.CheckAllClientsHealthAsync();
//
// // Assert
// results.Count.ShouldBe(2); // Only enabled clients
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000001"));
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000002"));
// results[new Guid("00000000-0000-0000-0000-000000000001")].IsHealthy.ShouldBeTrue();
// results[new Guid("00000000-0000-0000-0000-000000000002")].IsHealthy.ShouldBeFalse();
// }
//
// [Fact]
// public async Task ClientHealthChanged_ShouldRaiseEventOnHealthStateChange()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
//
// ClientHealthChangedEventArgs? capturedArgs = null;
// sut.ClientHealthChanged += (_, args) => capturedArgs = args;
//
// // Act - first check establishes initial state
// var firstResult = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Setup client to be unhealthy for second check
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Act - second check changes state
// var secondResult = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// capturedArgs.ShouldNotBeNull();
// capturedArgs.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001"));
// capturedArgs.Status.IsHealthy.ShouldBeFalse();
// capturedArgs.IsDegraded.ShouldBeTrue();
// capturedArgs.IsRecovered.ShouldBeFalse();
// }
//
// [Fact]
// public async Task GetClientHealth_ShouldReturnCachedStatus()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Perform a check to cache the status
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Act
// var result = sut.GetClientHealth(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// result.ShouldNotBeNull();
// result.IsHealthy.ShouldBeTrue();
// result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001"));
// }
//
// [Fact]
// public void GetClientHealth_WithNoCheck_ShouldReturnNull()
// {
// // Arrange
// var sut = _fixture.CreateSut();
//
// // Act
// var result = sut.GetClientHealth(new Guid("00000000-0000-0000-0000-000000000001"));
//
// // Assert
// result.ShouldBeNull();
// }
//
// [Fact]
// public async Task GetAllClientHealth_ShouldReturnAllCheckedClients()
// {
// // Arrange
// var sut = _fixture.CreateSut();
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000002"));
//
// // Perform checks to cache statuses
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000002"));
//
// // Act
// var results = sut.GetAllClientHealth();
//
// // Assert
// results.Count.ShouldBe(2);
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000001"));
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000002"));
// results[new Guid("00000000-0000-0000-0000-000000000001")].IsHealthy.ShouldBeTrue();
// results[new Guid("00000000-0000-0000-0000-000000000002")].IsHealthy.ShouldBeFalse();
// }
// }

View File

@@ -0,0 +1,148 @@
using Cleanuparr.Infrastructure.Models;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Models;
public class ValidationResultTests
{
[Fact]
public void Success_ReturnsValidResult()
{
// Act
var result = ValidationResult.Success();
// Assert
result.IsValid.ShouldBeTrue();
}
[Fact]
public void Success_HasEmptyErrorMessage()
{
// Act
var result = ValidationResult.Success();
// Assert
result.ErrorMessage.ShouldBe(string.Empty);
}
[Fact]
public void Success_HasEmptyDetails()
{
// Act
var result = ValidationResult.Success();
// Assert
result.Details.ShouldBeEmpty();
}
[Fact]
public void Failure_ReturnsInvalidResult()
{
// Act
var result = ValidationResult.Failure("Error occurred");
// Assert
result.IsValid.ShouldBeFalse();
}
[Fact]
public void Failure_ContainsErrorMessage()
{
// Arrange
const string errorMessage = "Validation failed";
// Act
var result = ValidationResult.Failure(errorMessage);
// Assert
result.ErrorMessage.ShouldBe(errorMessage);
}
[Fact]
public void Failure_WithDetails_ContainsAllDetails()
{
// Arrange
const string errorMessage = "Multiple errors";
var details = new List<string> { "Error 1", "Error 2", "Error 3" };
// Act
var result = ValidationResult.Failure(errorMessage, details);
// Assert
result.Details.Count.ShouldBe(3);
result.Details.ShouldContain("Error 1");
result.Details.ShouldContain("Error 2");
result.Details.ShouldContain("Error 3");
}
[Fact]
public void Failure_WithoutDetails_HasEmptyDetailsList()
{
// Act
var result = ValidationResult.Failure("Error");
// Assert
result.Details.ShouldNotBeNull();
result.Details.ShouldBeEmpty();
}
[Fact]
public void Failure_WithNullDetails_HasEmptyDetailsList()
{
// Act
var result = ValidationResult.Failure("Error", null);
// Assert
result.Details.ShouldNotBeNull();
result.Details.ShouldBeEmpty();
}
[Fact]
public void DefaultConstructor_IsValidIsFalse()
{
// Act
var result = new ValidationResult();
// Assert
result.IsValid.ShouldBeFalse();
}
[Fact]
public void DefaultConstructor_ErrorMessageIsEmpty()
{
// Act
var result = new ValidationResult();
// Assert
result.ErrorMessage.ShouldBe(string.Empty);
}
[Fact]
public void DefaultConstructor_DetailsIsEmptyList()
{
// Act
var result = new ValidationResult();
// Assert
result.Details.ShouldNotBeNull();
result.Details.ShouldBeEmpty();
}
[Fact]
public void Properties_CanBeSetDirectly()
{
// Arrange
var result = new ValidationResult();
// Act
result.IsValid = true;
result.ErrorMessage = "Test error";
result.Details = new List<string> { "Detail 1" };
// Assert
result.IsValid.ShouldBeTrue();
result.ErrorMessage.ShouldBe("Test error");
result.Details.ShouldContain("Detail 1");
}
}

View File

@@ -0,0 +1,178 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Cleanuparr.Domain.Entities.AppStatus;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class AppStatusRefreshServiceTests : IDisposable
{
private readonly Mock<ILogger<AppStatusRefreshService>> _loggerMock;
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly AppStatusSnapshot _snapshot;
private readonly JsonSerializerOptions _jsonOptions;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private AppStatusRefreshService? _service;
public AppStatusRefreshServiceTests()
{
_loggerMock = new Mock<ILogger<AppStatusRefreshService>>();
_hubContextMock = new Mock<IHubContext<AppHub>>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_snapshot = new AppStatusSnapshot();
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
_httpHandlerMock = new Mock<HttpMessageHandler>();
// Setup hub context
var clientsMock = new Mock<IHubClients>();
var clientProxyMock = new Mock<IClientProxy>();
clientsMock.Setup(c => c.All).Returns(clientProxyMock.Object);
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
}
public void Dispose()
{
_service?.Dispose();
}
private AppStatusRefreshService CreateService()
{
_service = new AppStatusRefreshService(
_loggerMock.Object,
_hubContextMock.Object,
_httpClientFactoryMock.Object,
_snapshot,
_jsonOptions);
return _service;
}
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
{
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(content, Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(_httpHandlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
}
#region Constructor Tests
[Fact]
public void Constructor_SetsAllDependencies()
{
// Act
var service = CreateService();
// Assert
Assert.NotNull(service);
}
#endregion
#region AppStatusSnapshot Integration Tests
[Fact]
public void AppStatusSnapshot_UpdateLatestVersion_ChangesStatusReturnsTrue()
{
// Arrange
var snapshot = new AppStatusSnapshot();
// Act
var result = snapshot.UpdateLatestVersion("1.0.0", out var status);
// Assert
Assert.True(result);
Assert.Equal("1.0.0", status.LatestVersion);
}
[Fact]
public void AppStatusSnapshot_UpdateLatestVersion_SameVersionReturnsFalse()
{
// Arrange
var snapshot = new AppStatusSnapshot();
snapshot.UpdateLatestVersion("1.0.0", out _);
// Act
var result = snapshot.UpdateLatestVersion("1.0.0", out var status);
// Assert
Assert.False(result);
Assert.Equal("1.0.0", status.LatestVersion);
}
[Fact]
public void AppStatusSnapshot_UpdateCurrentVersion_ChangesStatusReturnsTrue()
{
// Arrange
var snapshot = new AppStatusSnapshot();
// Act
var result = snapshot.UpdateCurrentVersion("2.0.0", out var status);
// Assert
Assert.True(result);
Assert.Equal("2.0.0", status.CurrentVersion);
}
[Fact]
public void AppStatusSnapshot_Current_ReturnsCurrentState()
{
// Arrange
var snapshot = new AppStatusSnapshot();
snapshot.UpdateCurrentVersion("1.0.0", out _);
snapshot.UpdateLatestVersion("2.0.0", out _);
// Act
var current = snapshot.Current;
// Assert
Assert.Equal("1.0.0", current.CurrentVersion);
Assert.Equal("2.0.0", current.LatestVersion);
}
[Fact]
public void AppStatusSnapshot_UpdateWithNull_HandlesCorrectly()
{
// Arrange
var snapshot = new AppStatusSnapshot();
snapshot.UpdateLatestVersion("1.0.0", out _);
// Act
var result = snapshot.UpdateLatestVersion(null, out var status);
// Assert
Assert.True(result);
Assert.Null(status.LatestVersion);
}
[Fact]
public void AppStatusSnapshot_UpdateWithSameNull_ReturnsFalse()
{
// Arrange
var snapshot = new AppStatusSnapshot();
// Act - Both are null initially
var result = snapshot.UpdateLatestVersion(null, out _);
// Assert
Assert.False(result);
}
#endregion
}

View File

@@ -0,0 +1,577 @@
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Moq;
using Quartz;
using Quartz.Impl.Matchers;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class JobManagementServiceTests
{
private readonly Mock<ILogger<JobManagementService>> _loggerMock;
private readonly Mock<ISchedulerFactory> _schedulerFactoryMock;
private readonly Mock<IScheduler> _schedulerMock;
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
private readonly JobManagementService _service;
public JobManagementServiceTests()
{
_loggerMock = new Mock<ILogger<JobManagementService>>();
_schedulerFactoryMock = new Mock<ISchedulerFactory>();
_schedulerMock = new Mock<IScheduler>();
_hubContextMock = new Mock<IHubContext<AppHub>>();
_schedulerFactoryMock.Setup(f => f.GetScheduler(It.IsAny<CancellationToken>()))
.ReturnsAsync(_schedulerMock.Object);
_service = new JobManagementService(_loggerMock.Object, _schedulerFactoryMock.Object, _hubContextMock.Object);
}
#region StartJob Tests
[Fact]
public async Task StartJob_WithInvalidDirectCronExpression_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var invalidCron = "invalid-cron";
// Act
var result = await _service.StartJob(jobType, directCronExpression: invalidCron);
// Assert
Assert.False(result);
}
[Fact]
public async Task StartJob_JobDoesNotExist_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("does not exist")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task StartJob_WithValidCronExpression_ReturnsTrue()
{
// Arrange
var jobType = JobType.QueueCleaner;
var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
_schedulerMock.Verify(s => s.ResumeJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StartJob_WithSchedule_ReturnsTrue()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.StartJob(jobType, schedule: schedule);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StartJob_WithNoScheduleOrCron_CreatesOneTimeTrigger()
{
// Arrange
var jobType = JobType.DownloadCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.StartJob(jobType);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(
It.Is<ITrigger>(t => t.Key.Name.Contains("onetime")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StartJob_CleansUpExistingTriggers_BeforeSchedulingNew()
{
// Arrange
var jobType = JobType.QueueCleaner;
var cronExpression = "0 0/5 * * * ?";
var existingTriggerMock = new Mock<ITrigger>();
existingTriggerMock.Setup(t => t.Key).Returns(new TriggerKey("existing-trigger"));
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger> { existingTriggerMock.Object });
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.UnscheduleJob(
It.Is<TriggerKey>(k => k.Name == "existing-trigger"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StartJob_WhenSchedulerThrows_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var cronExpression = "0 0/5 * * * ?";
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
// Assert
Assert.False(result);
}
#endregion
#region StopJob Tests
[Fact]
public async Task StopJob_JobDoesNotExist_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.StopJob(jobType);
// Assert
Assert.False(result);
}
[Fact]
public async Task StopJob_JobExists_CleansUpTriggersAndReturnsTrue()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var triggerMock = new Mock<ITrigger>();
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
// Act
var result = await _service.StopJob(jobType);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.UnscheduleJob(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task StopJob_WhenSchedulerThrows_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.StopJob(jobType);
// Assert
Assert.False(result);
}
#endregion
#region GetJob Tests
[Fact]
public async Task GetJob_JobDoesNotExist_ReturnsNotFoundStatus()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.GetJob(jobType);
// Assert
Assert.Equal("Not Found", result.Status);
Assert.Equal("QueueCleaner", result.Name);
}
[Fact]
public async Task GetJob_JobExistsNoTriggers_ReturnsNotScheduledStatus()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
// Act
var result = await _service.GetJob(jobType);
// Assert
Assert.Equal("Not Scheduled", result.Status);
}
[Theory]
[InlineData(TriggerState.Normal, "Scheduled")]
[InlineData(TriggerState.Paused, "Paused")]
[InlineData(TriggerState.Complete, "Complete")]
[InlineData(TriggerState.Error, "Error")]
[InlineData(TriggerState.Blocked, "Running")]
[InlineData(TriggerState.None, "Not Scheduled")]
public async Task GetJob_WithTrigger_ReturnsCorrectStatus(TriggerState triggerState, string expectedStatus)
{
// Arrange
var jobType = JobType.QueueCleaner;
var triggerMock = new Mock<ITrigger>();
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
triggerMock.Setup(t => t.GetNextFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(5));
triggerMock.Setup(t => t.GetPreviousFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(-5));
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
_schedulerMock.Setup(s => s.GetTriggerState(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(triggerState);
// Act
var result = await _service.GetJob(jobType);
// Assert
Assert.Equal(expectedStatus, result.Status);
}
[Fact]
public async Task GetJob_WhenSchedulerThrows_ReturnsErrorStatus()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetJob(jobType);
// Assert
Assert.Equal("Error", result.Status);
}
#endregion
#region GetAllJobs Tests
[Fact]
public async Task GetAllJobs_NoJobs_ReturnsEmptyList()
{
// Arrange
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string>());
// Act
var result = await _service.GetAllJobs();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task GetAllJobs_WithJobs_ReturnsJobList()
{
// Arrange
var jobKey = new JobKey("QueueCleaner");
var triggerMock = new Mock<ITrigger>();
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
triggerMock.Setup(t => t.GetNextFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(5));
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "DEFAULT" });
_schedulerMock.Setup(s => s.GetJobKeys(It.IsAny<GroupMatcher<JobKey>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HashSet<JobKey> { jobKey });
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
_schedulerMock.Setup(s => s.GetTriggerState(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TriggerState.Normal);
// Act
var result = await _service.GetAllJobs();
// Assert
Assert.Single(result);
Assert.Equal("QueueCleaner", result[0].Name);
Assert.Equal("Scheduled", result[0].Status);
}
[Fact]
public async Task GetAllJobs_WhenSchedulerThrows_ReturnsEmptyList()
{
// Arrange
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetAllJobs();
// Assert
Assert.Empty(result);
}
#endregion
#region TriggerJobOnce Tests
[Fact]
public async Task TriggerJobOnce_JobDoesNotExist_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.TriggerJobOnce(jobType);
// Assert
Assert.False(result);
}
[Fact]
public async Task TriggerJobOnce_JobExists_TriggersJobAndReturnsTrue()
{
// Arrange
var jobType = JobType.MalwareBlocker;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.TriggerJobOnce(jobType);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(
It.Is<ITrigger>(t => t.Key.Name.Contains("immediate") && t.Key.Name.Contains("manual")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task TriggerJobOnce_WhenSchedulerThrows_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.TriggerJobOnce(jobType);
// Assert
Assert.False(result);
}
#endregion
#region UpdateJobSchedule Tests
[Fact]
public async Task UpdateJobSchedule_NullSchedule_ThrowsArgumentNullException()
{
// Arrange
var jobType = JobType.QueueCleaner;
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => _service.UpdateJobSchedule(jobType, null!));
}
[Fact]
public async Task UpdateJobSchedule_JobDoesNotExist_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.UpdateJobSchedule(jobType, schedule);
// Assert
Assert.False(result);
}
[Fact]
public async Task UpdateJobSchedule_ValidSchedule_ReturnsTrue()
{
// Arrange
var jobType = JobType.DownloadCleaner;
var schedule = new JobSchedule { Every = 10, Type = ScheduleUnit.Minutes };
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ITrigger>());
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(DateTimeOffset.Now);
// Act
var result = await _service.UpdateJobSchedule(jobType, schedule);
// Assert
Assert.True(result);
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpdateJobSchedule_WhenSchedulerThrows_ReturnsFalse()
{
// Arrange
var jobType = JobType.QueueCleaner;
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.UpdateJobSchedule(jobType, schedule);
// Assert
Assert.False(result);
}
#endregion
#region GetMainTrigger Tests
[Fact]
public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetMainTrigger_TriggerExists_ReturnsTrigger()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger");
var triggerMock = new Mock<ITrigger>();
triggerMock.Setup(t => t.Key).Returns(expectedTriggerKey);
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_schedulerMock.Setup(s => s.GetTrigger(expectedTriggerKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(triggerMock.Object);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
Assert.NotNull(result);
Assert.Equal(expectedTriggerKey, result.Key);
}
[Fact]
public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
Assert.Null(result);
}
#endregion
}

View File

@@ -43,7 +43,7 @@ public class RuleEvaluatorTests
};
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -56,13 +56,12 @@ public class RuleEvaluatorTests
long downloadedBytes = 0;
var torrentMock = new Mock<ITorrentItem>();
var torrentMock = new Mock<ITorrentItemWrapper>();
torrentMock.SetupGet(t => t.Hash).Returns("hash");
torrentMock.SetupGet(t => t.Name).Returns("Example Torrent");
torrentMock.SetupGet(t => t.IsPrivate).Returns(false);
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse("100 MB").Bytes);
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
// Seed cache with initial observation (no reset expected)
@@ -91,7 +90,7 @@ public class RuleEvaluatorTests
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((StallRule?)null);
var torrentMock = CreateTorrentMock();
@@ -115,7 +114,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -144,7 +143,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -172,7 +171,7 @@ public class RuleEvaluatorTests
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(failingRule);
strikerMock
@@ -197,7 +196,7 @@ public class RuleEvaluatorTests
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((SlowRule?)null);
var torrentMock = CreateTorrentMock();
@@ -221,7 +220,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -249,7 +248,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -277,7 +276,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -304,7 +303,7 @@ public class RuleEvaluatorTests
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(failingRule);
strikerMock
@@ -336,7 +335,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -374,7 +373,7 @@ public class RuleEvaluatorTests
maxTimeHours: 2);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -408,7 +407,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
@@ -437,7 +436,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -469,7 +468,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
@@ -497,7 +496,7 @@ public class RuleEvaluatorTests
maxTimeHours: 2);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
var torrentMock = CreateTorrentMock();
@@ -525,7 +524,7 @@ public class RuleEvaluatorTests
maxTimeHours: 0);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -559,7 +558,7 @@ public class RuleEvaluatorTests
maxTimeHours: 1);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -588,7 +587,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -620,7 +619,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -644,7 +643,7 @@ public class RuleEvaluatorTests
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
}
private static Mock<ITorrentItem> CreateTorrentMock(
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
Func<long>? downloadedBytesFactory = null,
bool isPrivate = false,
string hash = "hash",
@@ -652,13 +651,12 @@ public class RuleEvaluatorTests
double completionPercentage = 50,
string size = "100 MB")
{
var torrentMock = new Mock<ITorrentItem>();
var torrentMock = new Mock<ITorrentItemWrapper>();
torrentMock.SetupGet(t => t.Hash).Returns(hash);
torrentMock.SetupGet(t => t.Name).Returns(name);
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytesFactory?.Invoke() ?? 0);
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
torrentMock.SetupGet(t => t.Eta).Returns(7200);
@@ -720,7 +718,7 @@ public class RuleEvaluatorTests
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((StallRule?)null);
var torrentMock = CreateTorrentMock();
@@ -745,7 +743,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -774,7 +772,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -803,7 +801,7 @@ public class RuleEvaluatorTests
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(stallRule);
strikerMock
@@ -830,7 +828,7 @@ public class RuleEvaluatorTests
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns((SlowRule?)null);
var torrentMock = CreateTorrentMock();
@@ -855,7 +853,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -884,7 +882,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -919,7 +917,7 @@ public class RuleEvaluatorTests
deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock
@@ -949,7 +947,7 @@ public class RuleEvaluatorTests
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
.Returns(slowRule);
strikerMock

View File

@@ -379,18 +379,17 @@ public class RuleManagerTests
Assert.Equal(slowRule.Id, result.Id);
}
private static Mock<ITorrentItem> CreateTorrentMock(
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
bool isPrivate = false,
double completionPercentage = 50,
string size = "100 MB")
{
var torrentMock = new Mock<ITorrentItem>();
var torrentMock = new Mock<ITorrentItemWrapper>();
torrentMock.SetupGet(t => t.Hash).Returns("test-hash");
torrentMock.SetupGet(t => t.Name).Returns("Test Torrent");
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(0);
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
torrentMock.SetupGet(t => t.Eta).Returns(3600);

View File

@@ -0,0 +1,339 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class StrikerTests : IDisposable
{
private readonly IMemoryCache _cache;
private readonly ILogger<Striker> _logger;
private readonly EventPublisher _eventPublisher;
private readonly Striker _striker;
public StrikerTests()
{
_cache = new MemoryCache(new MemoryCacheOptions());
_logger = Substitute.For<ILogger<Striker>>();
// Create EventPublisher with mocked dependencies
var eventsContext = CreateMockEventsContext();
var hubContext = Substitute.For<IHubContext<AppHub>>();
var hubClients = Substitute.For<IHubClients>();
var clientProxy = Substitute.For<IClientProxy>();
hubContext.Clients.Returns(hubClients);
hubClients.All.Returns(clientProxy);
var eventLogger = Substitute.For<ILogger<EventPublisher>>();
var notificationPublisher = Substitute.For<INotificationPublisher>();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
// Configure dry run interceptor to just complete the task (we don't need actual DB saves in tests)
dryRunInterceptor
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(Task.CompletedTask);
_eventPublisher = new EventPublisher(
eventsContext,
hubContext,
eventLogger,
notificationPublisher,
dryRunInterceptor);
_striker = new Striker(_logger, _cache, _eventPublisher);
// Clear static state before each test
Striker.RecurringHashes.Clear();
// Set up required context for recurring item events and FailedImport strikes
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
ContextProvider.Set("ArrInstanceUrl", new Uri("http://localhost:8989"));
ContextProvider.Set(new QueueRecord
{
Title = "Test Item",
DownloadId = "test-download-id",
Protocol = "torrent",
Id = 1,
StatusMessages = []
});
}
private static EventsContext CreateMockEventsContext()
{
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new EventsContext(options);
}
public void Dispose()
{
_cache.Dispose();
Striker.RecurringHashes.Clear();
}
[Fact]
public async Task StrikeAndCheckLimit_FirstStrike_ReturnsFalse()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 3;
// Act
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
result.ShouldBeFalse();
}
[Fact]
public async Task StrikeAndCheckLimit_ReachesMaxStrikes_ReturnsTrue()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 3;
// Act - Strike 3 times
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
result.ShouldBeTrue();
}
[Fact]
public async Task StrikeAndCheckLimit_ExceedsMaxStrikes_ReturnsTrue_AndAddsToRecurringHashes()
{
// Arrange
const string hash = "ABC123";
const string itemName = "Recurring Item";
const ushort maxStrikes = 2;
// Act - Strike 3 times (exceeds max of 2)
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
result.ShouldBeTrue();
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
}
[Fact]
public async Task StrikeAndCheckLimit_DifferentStrikeTypes_TrackedSeparately()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 2;
// Act - Strike with different types
var stalledResult1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var slowSpeedResult1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
var stalledResult2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var slowSpeedResult2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
// Assert - Both should reach max independently
stalledResult1.ShouldBeFalse();
slowSpeedResult1.ShouldBeFalse();
stalledResult2.ShouldBeTrue(); // 2nd stalled strike = maxStrikes
slowSpeedResult2.ShouldBeTrue(); // 2nd slow speed strike = maxStrikes
}
[Fact]
public async Task StrikeAndCheckLimit_SameHash_AccumulatesStrikes()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 5;
// Act - Strike 4 times
var result1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result3 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var result4 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert - None should trigger removal yet (need 5)
result1.ShouldBeFalse();
result2.ShouldBeFalse();
result3.ShouldBeFalse();
result4.ShouldBeFalse();
// 5th strike should trigger removal
var result5 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
result5.ShouldBeTrue();
}
[Fact]
public async Task ResetStrikeAsync_ClearsStrikeCount()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 3;
// Strike twice
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Act - Reset strikes
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled);
// Assert - Next strike should be treated as first (returns false)
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
result.ShouldBeFalse();
}
[Fact]
public async Task ResetStrikeAsync_OnlyResetsSpecifiedType()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 2;
// Strike with both types
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
// Act - Reset only Stalled strikes
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled);
// Assert - Stalled should be reset (1st strike = false), SlowSpeed should continue (2nd strike = true)
var stalledResult = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
var slowSpeedResult = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
stalledResult.ShouldBeFalse(); // Reset, so this is strike #1
slowSpeedResult.ShouldBeTrue(); // Not reset, so this is strike #2 = maxStrikes
}
[Fact]
public async Task StrikeAndCheckLimit_ZeroMaxStrikes_ReturnsFalse()
{
// Arrange
const string hash = "abc123";
const string itemName = "Test Item";
const ushort maxStrikes = 0;
// Act
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert - Should return false immediately without striking
result.ShouldBeFalse();
}
[Theory]
[InlineData((ushort)2, 0, false)] // Strike 1, max 2 -> below limit (1 < 2)
[InlineData((ushort)2, 1, true)] // Strike 2, max 2 -> at limit (2 >= 2)
[InlineData((ushort)3, 1, false)] // Strike 2, max 3 -> below limit (2 < 3)
[InlineData((ushort)3, 2, true)] // Strike 3, max 3 -> at limit (3 >= 3)
[InlineData((ushort)1, 0, true)] // Strike 1, max 1 -> at limit (1 >= 1)
public async Task StrikeAndCheckLimit_BoundaryConditions(ushort maxStrikes, int preStrikes, bool expectedResult)
{
// Arrange
const string hash = "boundary-test";
const string itemName = "Boundary Test Item";
// Pre-strike
for (int i = 0; i < preStrikes; i++)
{
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
}
// Act
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
result.ShouldBe(expectedResult);
}
[Theory]
[InlineData(StrikeType.Stalled)]
[InlineData(StrikeType.DownloadingMetadata)]
[InlineData(StrikeType.FailedImport)]
[InlineData(StrikeType.SlowSpeed)]
[InlineData(StrikeType.SlowTime)]
public async Task StrikeAndCheckLimit_AllStrikeTypes_WorkCorrectly(StrikeType strikeType)
{
// Arrange
const string hash = "type-test";
const string itemName = "Type Test Item";
const ushort maxStrikes = 2;
// Act
var result1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, strikeType);
var result2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, strikeType);
// Assert
result1.ShouldBeFalse();
result2.ShouldBeTrue();
}
[Fact]
public async Task StrikeAndCheckLimit_DifferentHashes_TrackedSeparately()
{
// Arrange
const string hash1 = "hash1";
const string hash2 = "hash2";
const string itemName = "Test Item";
const ushort maxStrikes = 2;
// Act - Strike hash1 twice, hash2 once
await _striker.StrikeAndCheckLimit(hash1, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash2, itemName, maxStrikes, StrikeType.Stalled);
var hash1Result = await _striker.StrikeAndCheckLimit(hash1, itemName, maxStrikes, StrikeType.Stalled);
var hash2Result = await _striker.StrikeAndCheckLimit(hash2, itemName, maxStrikes, StrikeType.Stalled);
// Assert
hash1Result.ShouldBeTrue(); // hash1 reached max (2 strikes)
hash2Result.ShouldBeTrue(); // hash2 reached max (2 strikes)
}
[Fact]
public async Task ResetStrikeAsync_NonExistentStrike_DoesNotThrow()
{
// Arrange
const string hash = "never-struck";
const string itemName = "Never Struck Item";
// Act & Assert - Should not throw
await Should.NotThrowAsync(async () =>
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled));
}
[Fact]
public async Task StrikeAndCheckLimit_RecurringItem_OnlyAddedOnceToRecurringHashes()
{
// Arrange
const string hash = "recurring-hash";
const string itemName = "Recurring Item";
const ushort maxStrikes = 1;
// Act - Strike multiple times past the limit
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert - Hash should only appear once in RecurringHashes
Striker.RecurringHashes.Count.ShouldBe(1);
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
}
}

Some files were not shown because too many files have changed in this diff Show More