mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375094862c | ||
|
|
58a72cef0f | ||
|
|
4ceff127a7 | ||
|
|
c07b811cf8 | ||
|
|
b16fa70774 | ||
|
|
b343165644 | ||
|
|
02dff0bb9b | ||
|
|
ac3be75082 | ||
|
|
a1663b865a | ||
|
|
c97a416d1e | ||
|
|
d28ab42303 | ||
|
|
fbb2bba3b6 | ||
|
|
08eda22587 |
30
.github/actions/vault-secrets/action.yml
vendored
Normal file
30
.github/actions/vault-secrets/action.yml
vendored
Normal 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 }}
|
||||
48
.github/workflows/build-docker.yml
vendored
48
.github/workflows/build-docker.yml
vendored
@@ -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
|
||||
|
||||
139
.github/workflows/build-executable.yml
vendored
139
.github/workflows/build-executable.yml
vendored
@@ -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
46
.github/workflows/build-frontend.yml
vendored
Normal 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
|
||||
@@ -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
|
||||
366
.github/workflows/build-macos-intel-installer.yml
vendored
366
.github/workflows/build-macos-intel-installer.yml
vendored
@@ -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
|
||||
41
.github/workflows/build-windows-installer.yml
vendored
41
.github/workflows/build-windows-installer.yml
vendored
@@ -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
45
.github/workflows/dependency-review.yml
vendored
Normal 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
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -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
|
||||
|
||||
|
||||
241
.github/workflows/release.yml
vendored
241
.github/workflows/release.yml
vendored
@@ -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
99
.github/workflows/test.yml
vendored
Normal 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
66
.github/workflows/version-info.yml
vendored
Normal 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}"
|
||||
@@ -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
|
||||
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
|
||||
|
||||
[](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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -14,18 +15,15 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly ArrClientFactory _arrClientFactory;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public StatusController(
|
||||
ILogger<StatusController> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
ArrClientFactory arrClientFactory)
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
@@ -179,7 +177,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
|
||||
await sonarrClient.TestConnectionAsync(instance);
|
||||
await sonarrClient.HealthCheckAsync(instance);
|
||||
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
@@ -211,7 +209,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
|
||||
await radarrClient.TestConnectionAsync(instance);
|
||||
await radarrClient.HealthCheckAsync(instance);
|
||||
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
@@ -243,7 +241,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
|
||||
await lidarrClient.TestConnectionAsync(instance);
|
||||
await lidarrClient.HealthCheckAsync(instance);
|
||||
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
|
||||
@@ -2,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();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -12,6 +13,7 @@ public static class NotificationsDI
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
@@ -10,7 +12,6 @@ using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
@@ -23,20 +24,18 @@ public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddScoped<IEncryptionService, AesEncryptionService>()
|
||||
.AddScoped<SensitiveDataJsonConverter>()
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<EventPublisher>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddScoped<CertificateValidationService>()
|
||||
.AddScoped<SonarrClient>()
|
||||
.AddScoped<RadarrClient>()
|
||||
.AddScoped<LidarrClient>()
|
||||
.AddScoped<ReadarrClient>()
|
||||
.AddScoped<WhisparrClient>()
|
||||
.AddScoped<ArrClientFactory>()
|
||||
.AddScoped<ISonarrClient, SonarrClient>()
|
||||
.AddScoped<IRadarrClient, RadarrClient>()
|
||||
.AddScoped<ILidarrClient, LidarrClient>()
|
||||
.AddScoped<IReadarrClient, ReadarrClient>()
|
||||
.AddScoped<IWhisparrClient, WhisparrClient>()
|
||||
.AddScoped<IArrClientFactory, ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
@@ -45,17 +44,18 @@ public static class ServicesDI
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<UnixHardLinkFileService>()
|
||||
.AddScoped<WindowsHardLinkFileService>()
|
||||
.AddScoped<ArrQueueIterator>()
|
||||
.AddScoped<DownloadServiceFactory>()
|
||||
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
|
||||
.AddScoped<IWindowsHardLinkFileService, WindowsHardLinkFileService>()
|
||||
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IRuleManager, RuleManager>()
|
||||
.AddScoped<IRuleEvaluator, RuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<BlocklistProvider>()
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record TestArrInstanceRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Mapster;
|
||||
@@ -20,13 +21,16 @@ public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public ArrConfigController(
|
||||
ILogger<ArrConfigController> logger,
|
||||
DataContext dataContext)
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("sonarr")]
|
||||
@@ -124,6 +128,26 @@ public sealed class ArrConfigController : ControllerBase
|
||||
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Whisparr, id);
|
||||
|
||||
[HttpPost("sonarr/instances/test")]
|
||||
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPost("radarr/instances/test")]
|
||||
public Task<IActionResult> TestRadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPost("lidarr/instances/test")]
|
||||
public Task<IActionResult> TestLidarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPost("readarr/instances/test")]
|
||||
public Task<IActionResult> TestReadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPost("whisparr/instances/test")]
|
||||
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
private async Task<IActionResult> GetArrConfig(InstanceType type)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
@@ -260,6 +284,23 @@ public sealed class ArrConfigController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testInstance = request.ToTestInstance();
|
||||
var client = _arrClientFactory.GetClient(type);
|
||||
await client.HealthCheckAsync(testInstance);
|
||||
|
||||
return Ok(new { Message = $"Connection to {type} instance successful" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
|
||||
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetConfigActionName(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => nameof(GetSonarrConfig),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public record SeedingRuleRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
public double MaxRatio { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||
/// </summary>
|
||||
public double MinSeedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hours to seed before removing a download.
|
||||
/// </summary>
|
||||
public double MaxSeedTime { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to delete the source files when cleaning the download.
|
||||
/// </summary>
|
||||
public bool DeleteSourceFiles { get; init; } = true;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public record UpdateDownloadCleanerConfigRequest
|
||||
public sealed record UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
@@ -13,7 +11,7 @@ public record UpdateDownloadCleanerConfigRequest
|
||||
/// </summary>
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public List<CleanCategoryRequest> Categories { get; init; } = [];
|
||||
public List<SeedingRuleRequest> Categories { get; init; } = [];
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
@@ -26,30 +24,9 @@ public record UpdateDownloadCleanerConfigRequest
|
||||
|
||||
public bool UnlinkedUseTag { get; init; }
|
||||
|
||||
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
|
||||
public List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
|
||||
|
||||
public List<string> UnlinkedCategories { get; init; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
}
|
||||
|
||||
public record CleanCategoryRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
public double MaxRatio { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||
/// </summary>
|
||||
public double MinSeedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hours to seed before removing a download.
|
||||
/// </summary>
|
||||
public double MaxSeedTime { get; init; } = -1;
|
||||
}
|
||||
|
||||
@@ -80,22 +80,23 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
|
||||
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
|
||||
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
|
||||
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
|
||||
oldConfig.UnlinkedIgnoredRootDirs = newConfigDto.UnlinkedIgnoredRootDirs;
|
||||
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
oldConfig.Categories.Clear();
|
||||
|
||||
_dataContext.CleanCategories.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.SeedingRules.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
|
||||
|
||||
foreach (var categoryDto in newConfigDto.Categories)
|
||||
{
|
||||
_dataContext.CleanCategories.Add(new CleanCategory
|
||||
_dataContext.SeedingRules.Add(new SeedingRule
|
||||
{
|
||||
Name = categoryDto.Name,
|
||||
MaxRatio = categoryDto.MaxRatio,
|
||||
MinSeedTime = categoryDto.MinSeedTime,
|
||||
MaxSeedTime = categoryDto.MaxSeedTime,
|
||||
DeleteSourceFiles = categoryDto.DeleteSourceFiles,
|
||||
DownloadCleanerConfigId = oldConfig.Id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
public sealed record TestDownloadClientRequest
|
||||
{
|
||||
public DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToTestConfig() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Enabled = true,
|
||||
Name = "Test Client",
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
@@ -18,15 +19,18 @@ public sealed class DownloadClientController : ControllerBase
|
||||
private readonly ILogger<DownloadClientController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||
private readonly IDownloadServiceFactory _downloadServiceFactory;
|
||||
|
||||
public DownloadClientController(
|
||||
ILogger<DownloadClientController> logger,
|
||||
DataContext dataContext,
|
||||
IDynamicHttpClientFactory dynamicHttpClientFactory)
|
||||
IDynamicHttpClientFactory dynamicHttpClientFactory,
|
||||
IDownloadServiceFactory downloadServiceFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_dynamicHttpClientFactory = dynamicHttpClientFactory;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
}
|
||||
|
||||
[HttpGet("download_client")]
|
||||
@@ -146,4 +150,33 @@ public sealed class DownloadClientController : ControllerBase
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("download_client/test")]
|
||||
public async Task<IActionResult> TestDownloadClient([FromBody] TestDownloadClientRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
request.Validate();
|
||||
|
||||
var testConfig = request.ToTestConfig();
|
||||
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
|
||||
var healthResult = await downloadService.HealthCheckAsync();
|
||||
|
||||
if (healthResult.IsHealthy)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
Message = $"Connection to {request.TypeName} successful",
|
||||
ResponseTime = healthResult.ResponseTime.TotalMilliseconds
|
||||
});
|
||||
}
|
||||
|
||||
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
|
||||
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreatePushoverProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestPushoverProviderRequest
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdatePushoverProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -44,6 +44,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -68,6 +69,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
|
||||
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
})
|
||||
@@ -524,6 +526,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
@@ -583,12 +586,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Notifiarr provider");
|
||||
throw;
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,12 +627,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Apprise provider");
|
||||
throw;
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,12 +673,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully", Success = true });
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Ntfy provider");
|
||||
throw;
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,8 +704,207 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
|
||||
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("pushover")]
|
||||
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = newProvider.ApiToken,
|
||||
UserKey = newProvider.UserKey,
|
||||
Devices = newProvider.Devices,
|
||||
Priority = newProvider.Priority,
|
||||
Sound = newProvider.Sound,
|
||||
Retry = newProvider.Retry,
|
||||
Expire = newProvider.Expire,
|
||||
Tags = newProvider.Tags
|
||||
};
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
PushoverConfiguration = pushoverConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Pushover provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("pushover/{id:guid}")]
|
||||
public async Task<IActionResult> UpdatePushoverProvider(Guid id, [FromBody] UpdatePushoverProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Pushover);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Pushover provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = updatedProvider.ApiToken,
|
||||
UserKey = updatedProvider.UserKey,
|
||||
Devices = updatedProvider.Devices,
|
||||
Priority = updatedProvider.Priority,
|
||||
Sound = updatedProvider.Sound,
|
||||
Retry = updatedProvider.Retry,
|
||||
Expire = updatedProvider.Expire,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
|
||||
if (existingProvider.PushoverConfiguration != null)
|
||||
{
|
||||
pushoverConfig = pushoverConfig with { Id = existingProvider.PushoverConfiguration.Id };
|
||||
}
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
PushoverConfiguration = pushoverConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Pushover provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("pushover/test")]
|
||||
public async Task<IActionResult> TestPushoverProvider([FromBody] TestPushoverProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = testRequest.ApiToken,
|
||||
UserKey = testRequest.UserKey,
|
||||
Devices = testRequest.Devices,
|
||||
Priority = testRequest.Priority,
|
||||
Sound = testRequest.Sound,
|
||||
Retry = testRequest.Retry,
|
||||
Expire = testRequest.Expire,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = pushoverConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Pushover provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
namespace Cleanuparr.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy namespace shim; prefer <see cref="UpdateDownloadCleanerConfigRequest"/> from
|
||||
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
|
||||
/// </summary>
|
||||
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.UpdateDownloadCleanerConfigRequest instead")]
|
||||
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
|
||||
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
|
||||
public record UpdateDownloadCleanerConfigDto : UpdateDownloadCleanerConfigRequest;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy namespace shim; prefer <see cref="CleanCategoryRequest"/> from
|
||||
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
|
||||
/// </summary>
|
||||
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.CleanCategoryRequest instead")]
|
||||
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
|
||||
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
|
||||
public record CleanCategoryDto : CleanCategoryRequest;
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -4,49 +4,34 @@ namespace Cleanuparr.Domain.Entities;
|
||||
/// Universal abstraction for a torrent item across all download clients.
|
||||
/// Provides a unified interface for accessing torrent properties and state.
|
||||
/// </summary>
|
||||
public interface ITorrentItem
|
||||
public interface ITorrentItemWrapper
|
||||
{
|
||||
// Basic identification
|
||||
string Hash { get; }
|
||||
|
||||
string Name { get; }
|
||||
|
||||
// Privacy and tracking
|
||||
bool IsPrivate { get; }
|
||||
IReadOnlyList<string> Trackers { get; }
|
||||
|
||||
// Size and progress
|
||||
long Size { get; }
|
||||
|
||||
double CompletionPercentage { get; }
|
||||
|
||||
long DownloadedBytes { get; }
|
||||
long TotalUploaded { get; }
|
||||
|
||||
// Speed and transfer rates
|
||||
long DownloadSpeed { get; }
|
||||
long UploadSpeed { get; }
|
||||
|
||||
double Ratio { get; }
|
||||
|
||||
// Time tracking
|
||||
long Eta { get; }
|
||||
DateTime? DateAdded { get; }
|
||||
DateTime? DateCompleted { get; }
|
||||
|
||||
long SeedingTimeSeconds { get; }
|
||||
|
||||
// Categories and tags
|
||||
string? Category { get; }
|
||||
IReadOnlyList<string> Tags { get; }
|
||||
string? Category { get; set; }
|
||||
|
||||
// State checking methods
|
||||
bool IsDownloading();
|
||||
|
||||
bool IsStalled();
|
||||
bool IsSeeding();
|
||||
bool IsCompleted();
|
||||
bool IsPaused();
|
||||
bool IsQueued();
|
||||
bool IsChecking();
|
||||
bool IsAllocating();
|
||||
bool IsMetadataDownloading();
|
||||
|
||||
// Filtering methods
|
||||
/// <summary>
|
||||
/// Determines if this torrent should be ignored based on the provided patterns.
|
||||
/// Checks if any pattern matches the torrent name, hash, or tracker.
|
||||
@@ -4,5 +4,6 @@ public enum NotificationProviderType
|
||||
{
|
||||
Notifiarr,
|
||||
Apprise,
|
||||
Ntfy
|
||||
Ntfy,
|
||||
Pushover
|
||||
}
|
||||
|
||||
10
code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs
Normal file
10
code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum PushoverPriority
|
||||
{
|
||||
Lowest = -2,
|
||||
Low = -1,
|
||||
Normal = 0,
|
||||
High = 1,
|
||||
Emergency = 2
|
||||
}
|
||||
36
code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs
Normal file
36
code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public static class PushoverSounds
|
||||
{
|
||||
public const string Pushover = "pushover";
|
||||
public const string Bike = "bike";
|
||||
public const string Bugle = "bugle";
|
||||
public const string Cashregister = "cashregister";
|
||||
public const string Classical = "classical";
|
||||
public const string Cosmic = "cosmic";
|
||||
public const string Falling = "falling";
|
||||
public const string Gamelan = "gamelan";
|
||||
public const string Incoming = "incoming";
|
||||
public const string Intermission = "intermission";
|
||||
public const string Magic = "magic";
|
||||
public const string Mechanical = "mechanical";
|
||||
public const string Pianobar = "pianobar";
|
||||
public const string Siren = "siren";
|
||||
public const string Spacealarm = "spacealarm";
|
||||
public const string Tugboat = "tugboat";
|
||||
public const string Alien = "alien";
|
||||
public const string Climb = "climb";
|
||||
public const string Persistent = "persistent";
|
||||
public const string Echo = "echo";
|
||||
public const string Updown = "updown";
|
||||
public const string Vibrate = "vibrate";
|
||||
public const string None = "none";
|
||||
|
||||
public static readonly string[] All =
|
||||
[
|
||||
Pushover, Bike, Bugle, Cashregister, Classical, Cosmic, Falling,
|
||||
Gamelan, Incoming, Intermission, Magic, Mechanical, Pianobar,
|
||||
Siren, Spacealarm, Tugboat, Alien, Climb, Persistent, Echo,
|
||||
Updown, Vibrate, None
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
@@ -6,6 +6,10 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the cleanup logic that actually deletes events
|
||||
/// </summary>
|
||||
public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _dbName;
|
||||
|
||||
public EventCleanupServiceIntegrationTests()
|
||||
{
|
||||
_dbName = Guid.NewGuid().ToString();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Setup in-memory database
|
||||
services.AddDbContext<EventsContext>(options =>
|
||||
options.UseInMemoryDatabase(databaseName: _dbName));
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_loggerMock = new Mock<ILogger<EventCleanupService>>();
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
_context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
context.Database.EnsureDeleted();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupService_PreservesRecentEvents()
|
||||
{
|
||||
// Arrange - Add recent events (within retention period)
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
|
||||
context.Events.Add(new AppEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EventType = EventType.QueueItemDeleted,
|
||||
Message = "Recent event 1",
|
||||
Severity = EventSeverity.Information,
|
||||
Timestamp = DateTime.UtcNow.AddDays(-5)
|
||||
});
|
||||
context.Events.Add(new AppEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EventType = EventType.DownloadCleaned,
|
||||
Message = "Recent event 2",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTime.UtcNow.AddDays(-10)
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Verify events exist
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
var count = await context.Events.CountAsync();
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventCleanupService_CanStartAndStop()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.CancelAfter(100);
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Give some time for the service to process
|
||||
await Task.Delay(150);
|
||||
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - the service should complete without throwing
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventCleanupService_HandlesExceptionsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
// Note: In-memory provider doesn't support ExecuteDeleteAsync,
|
||||
// so the cleanup will fail. This test verifies the service handles errors gracefully.
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.CancelAfter(100);
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(150);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - the service should handle the error and continue (log it but not crash)
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to perform event cleanup")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
|
||||
public class EventCleanupServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
|
||||
private readonly ServiceCollection _services;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _dbName;
|
||||
|
||||
public EventCleanupServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<EventCleanupService>>();
|
||||
_services = new ServiceCollection();
|
||||
_dbName = Guid.NewGuid().ToString();
|
||||
|
||||
// Setup in-memory database for testing
|
||||
_services.AddDbContext<EventsContext>(options =>
|
||||
options.UseInMemoryDatabase(databaseName: _dbName));
|
||||
|
||||
_serviceProvider = _services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup the in-memory database
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
context.Database.EnsureDeleted();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_LogsStartMessage()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - start and immediately cancel
|
||||
cts.CancelAfter(100);
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200); // Give it time to process
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("started")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_LogsStopMessage()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.CancelAfter(50);
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopping")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithCorrectParameters()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
|
||||
// Act
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
|
||||
// Assert - service should be created without exception
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_GracefullyHandlesCancellation()
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - cancel immediately
|
||||
cts.Cancel();
|
||||
|
||||
// Start should not throw
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should have logged stopped message
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopped")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
|
||||
public class EventPublisherTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
|
||||
private readonly Mock<ILogger<EventPublisher>> _loggerMock;
|
||||
private readonly Mock<INotificationPublisher> _notificationPublisherMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly Mock<IClientProxy> _clientProxyMock;
|
||||
private readonly EventPublisher _publisher;
|
||||
|
||||
public EventPublisherTests()
|
||||
{
|
||||
// Setup in-memory database
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new EventsContext(options);
|
||||
|
||||
// Setup mocks
|
||||
_hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
_loggerMock = new Mock<ILogger<EventPublisher>>();
|
||||
_notificationPublisherMock = new Mock<INotificationPublisher>();
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
_clientProxyMock = new Mock<IClientProxy>();
|
||||
|
||||
// Setup HubContext to return client proxy
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object);
|
||||
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
// Setup dry run interceptor to execute the delegate
|
||||
_dryRunInterceptorMock.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns<Delegate, object[]>(async (del, args) =>
|
||||
{
|
||||
if (del is Func<AppEvent, Task> func && args.Length > 0 && args[0] is AppEvent appEvent)
|
||||
{
|
||||
await func(appEvent);
|
||||
}
|
||||
else if (del is Func<ManualEvent, Task> manualFunc && args.Length > 0 && args[0] is ManualEvent manualEvent)
|
||||
{
|
||||
await manualFunc(manualEvent);
|
||||
}
|
||||
});
|
||||
|
||||
_publisher = new EventPublisher(
|
||||
_context,
|
||||
_hubContextMock.Object,
|
||||
_loggerMock.Object,
|
||||
_notificationPublisherMock.Object,
|
||||
_dryRunInterceptorMock.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.EnsureDeleted();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
#region PublishAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_SavesEventToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.QueueItemDeleted;
|
||||
var message = "Test message";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(eventType, savedEvent.EventType);
|
||||
Assert.Equal(message, savedEvent.Message);
|
||||
Assert.Equal(severity, savedEvent.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WithData_SerializesDataToJson()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.DownloadCleaned;
|
||||
var message = "Download cleaned";
|
||||
var severity = EventSeverity.Information;
|
||||
var data = new { Name = "TestDownload", Hash = "abc123" };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, data);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("TestDownload", savedEvent.Data);
|
||||
Assert.Contains("abc123", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WithTrackingId_SavesTrackingId()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Strike received";
|
||||
var severity = EventSeverity.Warning;
|
||||
var trackingId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, trackingId: trackingId);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(trackingId, savedEvent.TrackingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_NotifiesSignalRClients()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.CategoryChanged;
|
||||
var message = "Category changed";
|
||||
var severity = EventSeverity.Information;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenSignalRFails_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.QueueItemDeleted;
|
||||
var message = "Test message";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
_clientProxyMock.Setup(c => c.SendCoreAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<object[]>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("SignalR connection failed"));
|
||||
|
||||
// Act - should not throw
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert - verify event was still saved
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_NullData_DoesNotSerialize()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.DownloadCleaned;
|
||||
var message = "Test";
|
||||
var severity = EventSeverity.Information;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, data: null);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Null(savedEvent.Data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishManualAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_SavesManualEventToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual event message";
|
||||
var severity = EventSeverity.Warning;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(message, savedEvent.Message);
|
||||
Assert.Equal(severity, savedEvent.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_WithData_SerializesDataToJson()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual event";
|
||||
var severity = EventSeverity.Important;
|
||||
var data = new { ItemName = "TestItem", Count = 5 };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity, data);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("TestItem", savedEvent.Data);
|
||||
Assert.Contains("5", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_NotifiesSignalRClients()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual event";
|
||||
var severity = EventSeverity.Information;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
"ManualEventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DryRun Interceptor Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_UsesDryRunInterceptor()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Test";
|
||||
var severity = EventSeverity.Warning;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
|
||||
It.IsAny<Delegate>(),
|
||||
It.IsAny<object[]>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_UsesDryRunInterceptor()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual test";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
|
||||
It.IsAny<Delegate>(),
|
||||
It.IsAny<object[]>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Serialization Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_SerializesEnumsAsStrings()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.QueueItemDeleted;
|
||||
var message = "Test";
|
||||
var severity = EventSeverity.Important;
|
||||
var data = new { Reason = DeleteReason.Stalled };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, data);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Stalled", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_HandlesComplexData()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.DownloadCleaned;
|
||||
var message = "Test";
|
||||
var severity = EventSeverity.Information;
|
||||
var data = new
|
||||
{
|
||||
Items = new[] { "item1", "item2" },
|
||||
Nested = new { Value = 123 },
|
||||
NullableValue = (string?)null
|
||||
};
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity, data);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("item1", savedEvent.Data);
|
||||
Assert.Contains("123", savedEvent.Data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishQueueItemDeleted Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishQueueItemDeleted_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "abc123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishQueueItemDeleted(removeFromClient: true, DeleteReason.Stalled);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventType.QueueItemDeleted, savedEvent.EventType);
|
||||
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Test Download", savedEvent.Data);
|
||||
Assert.Contains("abc123", savedEvent.Data);
|
||||
Assert.Contains("Stalled", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishQueueItemDeleted_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "abc123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyQueueItemDeleted(false, DeleteReason.FailedImport), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishDownloadCleaned Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishDownloadCleaned_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Cleaned Download");
|
||||
ContextProvider.Set("hash", "def456");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishDownloadCleaned(
|
||||
ratio: 2.5,
|
||||
seedingTime: TimeSpan.FromHours(48),
|
||||
categoryName: "movies",
|
||||
reason: CleanReason.MaxSeedTimeReached);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventType.DownloadCleaned, savedEvent.EventType);
|
||||
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Cleaned Download", savedEvent.Data);
|
||||
Assert.Contains("def456", savedEvent.Data);
|
||||
Assert.Contains("movies", savedEvent.Data);
|
||||
Assert.Contains("MaxSeedTimeReached", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishDownloadCleaned_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test");
|
||||
ContextProvider.Set("hash", "xyz");
|
||||
|
||||
var ratio = 1.5;
|
||||
var seedingTime = TimeSpan.FromHours(24);
|
||||
var categoryName = "tv";
|
||||
var reason = CleanReason.MaxRatioReached;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishDownloadCleaned(ratio, seedingTime, categoryName, reason);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishSearchNotTriggered Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchNotTriggered_SavesManualEvent()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:8989"));
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchNotTriggered("abc123", "Test Item");
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventSeverity.Warning, savedEvent.Severity);
|
||||
Assert.Contains("Replacement search was not triggered", savedEvent.Message);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Test Item", savedEvent.Data);
|
||||
Assert.Contains("abc123", savedEvent.Data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishRecurringItem Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishRecurringItem_SavesManualEvent()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Radarr);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://localhost:7878"));
|
||||
|
||||
// Act
|
||||
await _publisher.PublishRecurringItem("hash123", "Recurring Item", 5);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventSeverity.Important, savedEvent.Severity);
|
||||
Assert.Contains("keeps coming back", savedEvent.Message);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Recurring Item", savedEvent.Data);
|
||||
Assert.Contains("hash123", savedEvent.Data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishCategoryChanged Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishCategoryChanged_SavesEventWithContextData()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Category Test");
|
||||
ContextProvider.Set("hash", "cat123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("oldCat", "newCat", isTag: false);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventType.CategoryChanged, savedEvent.EventType);
|
||||
Assert.Equal(EventSeverity.Information, savedEvent.Severity);
|
||||
Assert.Contains("Category changed from 'oldCat' to 'newCat'", savedEvent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishCategoryChanged_WithTag_SavesCorrectMessage()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Tag Test");
|
||||
ContextProvider.Set("hash", "tag123");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("", "cleanuperr-done", isTag: true);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Contains("Tag 'cleanuperr-done' added", savedEvent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishCategoryChanged_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
ContextProvider.Set("downloadName", "Test");
|
||||
ContextProvider.Set("hash", "xyz");
|
||||
|
||||
// Act
|
||||
await _publisher.PublishCategoryChanged("old", "new", isTag: true);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyCategoryChanged("old", "new", true), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
|
||||
|
||||
public class ArrClientFactoryTests
|
||||
{
|
||||
private readonly Mock<ISonarrClient> _sonarrClientMock;
|
||||
private readonly Mock<IRadarrClient> _radarrClientMock;
|
||||
private readonly Mock<ILidarrClient> _lidarrClientMock;
|
||||
private readonly Mock<IReadarrClient> _readarrClientMock;
|
||||
private readonly Mock<IWhisparrClient> _whisparrClientMock;
|
||||
private readonly ArrClientFactory _factory;
|
||||
|
||||
public ArrClientFactoryTests()
|
||||
{
|
||||
_sonarrClientMock = new Mock<ISonarrClient>();
|
||||
_radarrClientMock = new Mock<IRadarrClient>();
|
||||
_lidarrClientMock = new Mock<ILidarrClient>();
|
||||
_readarrClientMock = new Mock<IReadarrClient>();
|
||||
_whisparrClientMock = new Mock<IWhisparrClient>();
|
||||
|
||||
_factory = new ArrClientFactory(
|
||||
_sonarrClientMock.Object,
|
||||
_radarrClientMock.Object,
|
||||
_lidarrClientMock.Object,
|
||||
_readarrClientMock.Object,
|
||||
_whisparrClientMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region GetClient Tests
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Sonarr_ReturnsSonarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Sonarr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_sonarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Radarr_ReturnsRadarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Radarr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_radarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Lidarr_ReturnsLidarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Lidarr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_lidarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Readarr_ReturnsReadarrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Readarr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_readarrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_Whisparr_ReturnsWhisparrClient()
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Whisparr);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_whisparrClientMock.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetClient_UnsupportedType_ThrowsNotImplementedException()
|
||||
{
|
||||
// Arrange
|
||||
var unsupportedType = (InstanceType)999;
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotImplementedException>(() => _factory.GetClient(unsupportedType));
|
||||
Assert.Contains("not yet supported", exception.Message);
|
||||
Assert.Contains("999", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public void GetClient_AllSupportedTypes_ReturnsNonNullClient(InstanceType instanceType)
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(instanceType);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsAssignableFrom<IArrClient>(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public void GetClient_CalledMultipleTimes_ReturnsSameInstance(InstanceType instanceType)
|
||||
{
|
||||
// Act
|
||||
var result1 = _factory.GetClient(instanceType);
|
||||
var result2 = _factory.GetClient(instanceType);
|
||||
|
||||
// Assert
|
||||
Assert.Same(result1, result2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using System.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.BlacklistSync;
|
||||
|
||||
public class BlacklistSynchronizerTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<BlacklistSynchronizer>> _loggerMock;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly Mock<IDownloadServiceFactory> _downloadServiceFactoryMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly BlacklistSynchronizer _synchronizer;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public BlacklistSynchronizerTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<BlacklistSynchronizer>>();
|
||||
|
||||
// Use SQLite in-memory with shared connection to support complex types
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_dataContext = new DataContext(options);
|
||||
_dataContext.Database.EnsureCreated();
|
||||
|
||||
_downloadServiceFactoryMock = new Mock<IDownloadServiceFactory>();
|
||||
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
// Setup interceptor to execute the action with params using DynamicInvoke
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
var result = action.DynamicInvoke(parameters);
|
||||
if (result is Task task)
|
||||
{
|
||||
return task;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Setup mock HTTP handler for FileReader
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
|
||||
var httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
_fileReader = new FileReader(httpClientFactoryMock.Object);
|
||||
|
||||
_synchronizer = new BlacklistSynchronizer(
|
||||
_loggerMock.Object,
|
||||
_dataContext,
|
||||
_downloadServiceFactoryMock.Object,
|
||||
_fileReader,
|
||||
_dryRunInterceptorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
#region ExecuteAsync - Disabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenDisabled_ReturnsEarlyWithoutProcessing()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: false);
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync - Path Not Configured Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenPathNotConfigured_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: null);
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("path is not configured")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenPathIsWhitespace_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: " ");
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("path is not configured")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync - No Clients Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenNoQBittorrentClients_LogsDebugAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse("pattern1\npattern2");
|
||||
|
||||
// Don't add any download clients
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenOnlyDelugeClients_LogsDebugAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse("pattern1\npattern2");
|
||||
|
||||
// Add only a Deluge client
|
||||
await AddDownloadClient(DownloadClientTypeName.Deluge, enabled: true);
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenDisabledQBittorrentClient_DoesNotProcess()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse("pattern1\npattern2");
|
||||
|
||||
// Add a disabled qBittorrent client
|
||||
await AddDownloadClient(DownloadClientTypeName.qBittorrent, enabled: false);
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync - Already Synced Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenClientAlreadySynced_SkipsClient()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = "pattern1\npattern2";
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse(patterns);
|
||||
|
||||
var clientId = await AddDownloadClient(DownloadClientTypeName.qBittorrent, enabled: true);
|
||||
|
||||
// Calculate the expected hash (same as ComputeHash in BlacklistSynchronizer)
|
||||
var cleanPatterns = string.Join('\n', patterns.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
var hash = ComputeHash(cleanPatterns);
|
||||
|
||||
// Add sync history for this client with the same hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = hash,
|
||||
DownloadClientId = clientId
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("already synced")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync - Dry Run Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UsesDryRunInterceptor()
|
||||
{
|
||||
// Arrange
|
||||
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
|
||||
SetupHttpResponse("pattern1\npattern2");
|
||||
|
||||
// Act
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert - Verify interceptor was called (with Delegate, not Func<object, object, Task>)
|
||||
_dryRunInterceptorMock.Verify(
|
||||
d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task SetupBlacklistSyncConfig(bool enabled, string? blacklistPath = null)
|
||||
{
|
||||
var config = new BlacklistSyncConfig
|
||||
{
|
||||
Enabled = enabled,
|
||||
BlacklistPath = blacklistPath
|
||||
};
|
||||
|
||||
_dataContext.BlacklistSyncConfigs.Add(config);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<Guid> AddDownloadClient(DownloadClientTypeName typeName, bool enabled)
|
||||
{
|
||||
var client = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Test {typeName} Client",
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://test.example.com"),
|
||||
Enabled = enabled
|
||||
};
|
||||
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return client.Id;
|
||||
}
|
||||
|
||||
private void SetupHttpResponse(string content)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(content)
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeItemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new DelugeItem(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = expectedHash,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = expectedName,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPrivate_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Private = true,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Size = expectedSize,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1024, 0.0)]
|
||||
[InlineData(512, 1024, 50.0)]
|
||||
[InlineData(768, 1024, 75.0)]
|
||||
[InlineData(1024, 1024, 100.0)]
|
||||
[InlineData(0, 0, 0.0)] // Edge case: zero size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
TotalDone = totalDone,
|
||||
Size = size,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker2.example.com/announce" },
|
||||
new() { Url = "udp://tracker3.example.com:1337/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker1.example.com/announce" },
|
||||
new() { Url = "udp://tracker1.example.com:1337/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://valid.example.com/announce" },
|
||||
new() { Url = "invalid-url" },
|
||||
new() { Url = "" },
|
||||
new() { Url = null! }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Trackers = null!,
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItem(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeItemWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new DelugeItemWrapper(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = expectedHash,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = expectedName,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Name = null,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPrivate_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Private = true,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Size = expectedSize,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSize);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1024, 0.0)]
|
||||
[InlineData(512, 1024, 50.0)]
|
||||
[InlineData(768, 1024, 75.0)]
|
||||
[InlineData(1024, 1024, 100.0)]
|
||||
[InlineData(0, 0, 0.0)] // Edge case: zero size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
TotalDone = totalDone,
|
||||
Size = size,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
|
||||
[InlineData(0L, 0L)]
|
||||
public void DownloadedBytes_ReturnsCorrectValue(long totalDone, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
TotalDone = totalDone,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2.0f, 2.0)]
|
||||
[InlineData(0.5f, 0.5)]
|
||||
[InlineData(1.0f, 1.0)]
|
||||
[InlineData(0.0f, 0.0)]
|
||||
public void Ratio_ReturnsCorrectValue(float ratio, double expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Ratio = ratio,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Ratio;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3600UL, 3600L)] // 1 hour
|
||||
[InlineData(0UL, 0L)]
|
||||
[InlineData(86400UL, 86400L)] // 1 day
|
||||
public void Eta_ReturnsCorrectValue(ulong eta, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Eta = eta,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(86400L, 86400L)] // 1 day
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(3600L, 3600L)] // 1 hour
|
||||
public void SeedingTimeSeconds_ReturnsCorrectValue(long seedingTime, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
SeedingTime = seedingTime,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
var ignoredDownloads = new[] { "abc123" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingCategory_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Label = "test-category",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
var ignoredDownloads = new[] { "test-category" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTracker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
var ignoredDownloads = new[] { "tracker.example.com" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_NotMatching_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Label = "some-category",
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
},
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
var ignoredDownloads = new[] { "notmatching" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024, 1024L * 1024)] // 1MB/s
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(500L, 500L)]
|
||||
public void DownloadSpeed_ReturnsCorrectValue(long downloadSpeed, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
DownloadSpeed = downloadSpeed,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadSpeed;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_Setter_SetsLabel()
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Label = "original-category",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
wrapper.Category = "new-category";
|
||||
|
||||
// Assert
|
||||
wrapper.Category.ShouldBe("new-category");
|
||||
downloadStatus.Label.ShouldBe("new-category");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Downloading", true)]
|
||||
[InlineData("downloading", true)]
|
||||
[InlineData("DOWNLOADING", true)]
|
||||
[InlineData("Seeding", false)]
|
||||
[InlineData("Paused", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsDownloading_ReturnsCorrectValue(string? state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
State = state,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsDownloading();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Downloading", 0, 0UL, true)] // Downloading with no speed and no ETA = stalled
|
||||
[InlineData("Downloading", 1000, 0UL, false)] // Has download speed = not stalled
|
||||
[InlineData("Downloading", 0, 100UL, false)] // Has ETA = not stalled
|
||||
[InlineData("Downloading", 1000, 100UL, false)] // Has both = not stalled
|
||||
[InlineData("Seeding", 0, 0UL, false)] // Not downloading state = not stalled
|
||||
[InlineData("Paused", 0, 0UL, false)] // Not downloading state = not stalled
|
||||
[InlineData(null, 0, 0UL, false)] // Null state = not stalled
|
||||
public void IsStalled_ReturnsCorrectValue(string? state, long downloadSpeed, ulong eta, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
State = state,
|
||||
DownloadSpeed = downloadSpeed,
|
||||
Eta = eta,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/test/path"
|
||||
};
|
||||
var wrapper = new DelugeItemWrapper(downloadStatus);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsStalled();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
private readonly DelugeServiceFixture _fixture;
|
||||
|
||||
public DelugeServiceDCTests(DelugeServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class GetSeedingDownloads_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public GetSeedingDownloads_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiltersSeedingState()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "Downloading", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, item => Assert.NotNull(item.Hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "SEEDING", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync((List<DownloadStatus>?)null);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "", Name = "No Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToBeCleanedAsync_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToBeCleanedAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, x => x.Category == "movies");
|
||||
Assert.Contains(result, x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmptyList_WhenNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToChangeCategoryAsync_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToChangeCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash2", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsDownloadsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }),
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCategoryAsync_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public CreateCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatesLabel_WhenMissing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.CreateLabel("new-label"))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("new-label");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel("new-label"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsCreation_WhenLabelExists()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string> { "existing" });
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("existing");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string> { "Existing" });
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("existing");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteDownload_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public DeleteDownload_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsClientDelete()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizesHashToLowercase()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "UPPERCASE-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsClientDeleteWithoutSourceFiles()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeCategoryForNoHardLinksAsync_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public ChangeCategoryForNoHardLinksAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingHash_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingName_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingCategory_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExceptionGettingFiles_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoHardlinks_ChangesLabel()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabel("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasHardlinks_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileNotFound_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkippedFiles_IgnoredInCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "file1.mkv" } },
|
||||
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "file2.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithIgnoredRootDir_PopulatesFileCounts()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked",
|
||||
UnlinkedIgnoredRootDirs = ["/ignore"]
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishesCategoryChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - EventPublisher is not mocked, so we just verify the method completed
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabel("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<DelugeService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IDelugeClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public DelugeServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<DelugeService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IDelugeClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public DelugeService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.Deluge,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8112"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = ""
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new DelugeService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
private readonly DelugeServiceFixture _fixture;
|
||||
|
||||
public DelugeServiceTests(DelugeServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_BasicScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentNotFound_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "nonexistent";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync((DownloadStatus?)null);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = true,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFilesUnwanted_DeletesFromClient()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0 } },
|
||||
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 1 } }
|
||||
}
|
||||
});
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SomeFilesWanted_DoesNotRemove()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0 } },
|
||||
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string category = "test-category";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Label = category,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByTrackerDomain_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string trackerDomain = "tracker.example.com";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>
|
||||
{
|
||||
new Tracker { Url = $"https://{trackerDomain}/announce" }
|
||||
},
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotDownloadingState_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Seeding",
|
||||
Private = false,
|
||||
DownloadSpeed = 0,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 0,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : DelugeServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
DownloadSpeed = 0,
|
||||
Eta = 0,
|
||||
Private = false,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DownloadServiceFactoryTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<DownloadServiceFactory>> _loggerMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly DownloadServiceFactory _factory;
|
||||
private readonly MemoryCache _memoryCache;
|
||||
|
||||
public DownloadServiceFactoryTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DownloadServiceFactory>>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Use real MemoryCache - mocks don't work properly with cache operations
|
||||
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
|
||||
services.AddSingleton<IMemoryCache>(_memoryCache);
|
||||
|
||||
// Register loggers
|
||||
services.AddSingleton(Mock.Of<ILogger<QBitService>>());
|
||||
services.AddSingleton(Mock.Of<ILogger<DelugeService>>());
|
||||
services.AddSingleton(Mock.Of<ILogger<TransmissionService>>());
|
||||
services.AddSingleton(Mock.Of<ILogger<UTorrentService>>());
|
||||
|
||||
services.AddSingleton(Mock.Of<IFilenameEvaluator>());
|
||||
services.AddSingleton(Mock.Of<IStriker>());
|
||||
services.AddSingleton(Mock.Of<IDryRunInterceptor>());
|
||||
services.AddSingleton(Mock.Of<IHardLinkFileService>());
|
||||
|
||||
// IDynamicHttpClientProvider must return a real HttpClient for download services
|
||||
var httpClientProviderMock = new Mock<IDynamicHttpClientProvider>();
|
||||
httpClientProviderMock.Setup(p => p.CreateClient(It.IsAny<DownloadClientConfig>())).Returns(new HttpClient());
|
||||
services.AddSingleton(httpClientProviderMock.Object);
|
||||
|
||||
services.AddSingleton(Mock.Of<IRuleEvaluator>());
|
||||
services.AddSingleton(Mock.Of<IRuleManager>());
|
||||
|
||||
// UTorrentService needs ILoggerFactory
|
||||
services.AddLogging();
|
||||
|
||||
// EventPublisher requires specific constructor arguments
|
||||
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var eventsContext = new EventsContext(eventsContextOptions);
|
||||
var hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
|
||||
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
services.AddSingleton<IEventPublisher>(new EventPublisher(
|
||||
eventsContext,
|
||||
hubContextMock.Object,
|
||||
Mock.Of<ILogger<EventPublisher>>(),
|
||||
Mock.Of<INotificationPublisher>(),
|
||||
Mock.Of<IDryRunInterceptor>()));
|
||||
|
||||
// BlocklistProvider requires specific constructor arguments
|
||||
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
|
||||
|
||||
services.AddSingleton<IBlocklistProvider>(new BlocklistProvider(
|
||||
Mock.Of<ILogger<BlocklistProvider>>(),
|
||||
scopeFactoryMock.Object,
|
||||
_memoryCache));
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_factory = new DownloadServiceFactory(_loggerMock.Object, _serviceProvider);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_memoryCache.Dispose();
|
||||
}
|
||||
|
||||
#region GetDownloadService Tests
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_QBittorrent_ReturnsQBitService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<QBitService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_Deluge_ReturnsDelugeService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.Deluge);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<DelugeService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_Transmission_ReturnsTransmissionService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.Transmission);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<TransmissionService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_UTorrent_ReturnsUTorrentService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.uTorrent);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<UTorrentService>(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_UnsupportedType_ThrowsNotSupportedException()
|
||||
{
|
||||
// Arrange
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Unsupported Client",
|
||||
TypeName = (DownloadClientTypeName)999, // Invalid type
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://test.example.com"),
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotSupportedException>(() => _factory.GetDownloadService(config));
|
||||
Assert.Contains("not supported", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_DisabledClient_LogsWarningButReturnsService()
|
||||
{
|
||||
// Arrange
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Disabled qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://test.example.com"),
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_EnabledClient_DoesNotLogWarning()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DownloadClientTypeName.qBittorrent, typeof(QBitService))]
|
||||
[InlineData(DownloadClientTypeName.Deluge, typeof(DelugeService))]
|
||||
[InlineData(DownloadClientTypeName.Transmission, typeof(TransmissionService))]
|
||||
[InlineData(DownloadClientTypeName.uTorrent, typeof(UTorrentService))]
|
||||
public void GetDownloadService_AllSupportedTypes_ReturnCorrectServiceType(
|
||||
DownloadClientTypeName typeName, Type expectedServiceType)
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(typeName);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType(expectedServiceType, service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_ReturnsNewInstanceEachTime()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
|
||||
|
||||
// Act
|
||||
var service1 = _factory.GetDownloadService(config);
|
||||
var service2 = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(service1, service2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static DownloadClientConfig CreateClientConfig(DownloadClientTypeName typeName)
|
||||
{
|
||||
return new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Test {typeName} Client",
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://test.example.com"),
|
||||
Enabled = true
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class QBitItemTests
|
||||
public class QBitItemWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
|
||||
@@ -14,7 +14,7 @@ public class QBitItemTests
|
||||
var trackers = new List<TorrentTracker>();
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItem(null!, trackers, false));
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItemWrapper(null!, trackers, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -24,7 +24,7 @@ public class QBitItemTests
|
||||
var torrentInfo = new TorrentInfo();
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItem(torrentInfo, null!, false));
|
||||
Should.Throw<ArgumentNullException>(() => new QBitItemWrapper(torrentInfo, null!, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -34,7 +34,7 @@ public class QBitItemTests
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentInfo = new TorrentInfo { Hash = expectedHash };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
@@ -49,7 +49,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Hash = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
@@ -65,7 +65,7 @@ public class QBitItemTests
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentInfo = new TorrentInfo { Name = expectedName };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
@@ -80,7 +80,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
@@ -95,7 +95,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, true);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, true);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
@@ -111,7 +111,7 @@ public class QBitItemTests
|
||||
var expectedSize = 1024L * 1024 * 1024; // 1GB
|
||||
var torrentInfo = new TorrentInfo { Size = expectedSize };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
@@ -126,7 +126,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Size = 0 };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
@@ -145,7 +145,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Progress = progress };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
@@ -155,86 +155,210 @@ public class QBitItemTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
public void DownloadedBytes_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker2.example.com/announce" },
|
||||
new() { Url = "udp://tracker3.example.com:1337/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Url = "https://tracker1.example.com/announce" },
|
||||
new() { Url = "udp://tracker1.example.com:1337/announce" }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://valid.example.com/announce" },
|
||||
new() { Url = "invalid-url" },
|
||||
new() { Url = "" },
|
||||
new() { Url = null }
|
||||
};
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var expectedDownloaded = 1024L * 1024 * 500; // 500MB
|
||||
var torrentInfo = new TorrentInfo { Downloaded = expectedDownloaded };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedDownloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadedBytes_WithNullValue_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Downloaded = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadSpeed_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedSpeed = 1024 * 512; // 512 KB/s
|
||||
var torrentInfo = new TorrentInfo { DownloadSpeed = expectedSpeed };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadSpeed;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedSpeed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.5)]
|
||||
[InlineData(1.0)]
|
||||
[InlineData(2.5)]
|
||||
public void Ratio_ReturnsCorrectValue(double expectedRatio)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Ratio = expectedRatio };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Ratio;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedRatio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eta_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedEta = TimeSpan.FromMinutes(30);
|
||||
var torrentInfo = new TorrentInfo { EstimatedTime = expectedEta };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe((long)expectedEta.TotalSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eta_WithNullValue_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { EstimatedTime = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedingTimeSeconds_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTime = TimeSpan.FromHours(5);
|
||||
var torrentInfo = new TorrentInfo { SeedingTime = expectedTime };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe((long)expectedTime.TotalSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedingTimeSeconds_WithNullValue_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { SeedingTime = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTags = new List<string> { "tag1", "tag2", "tag3" };
|
||||
var torrentInfo = new TorrentInfo { Tags = expectedTags.AsReadOnly() };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedTags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_WithNullValue_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Tags = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Tags = new List<string>().AsReadOnly() };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedCategory = "movies";
|
||||
var torrentInfo = new TorrentInfo { Category = expectedCategory };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Category;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedCategory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_WithNullValue_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Category = null };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Category;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// State checking method tests
|
||||
[Theory]
|
||||
[InlineData(TorrentState.Downloading, true)]
|
||||
@@ -247,7 +371,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsDownloading();
|
||||
@@ -266,7 +390,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsStalled();
|
||||
@@ -286,7 +410,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsSeeding();
|
||||
@@ -295,101 +419,6 @@ public class QBitItemTests
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, false)]
|
||||
[InlineData(0.5, false)]
|
||||
[InlineData(0.99, false)]
|
||||
[InlineData(1.0, true)]
|
||||
public void IsCompleted_ReturnsCorrectValue(double progress, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Progress = progress };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsCompleted();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.PausedDownload, true)]
|
||||
[InlineData(TorrentState.PausedUpload, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsPaused_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPaused();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.QueuedDownload, true)]
|
||||
[InlineData(TorrentState.QueuedUpload, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsQueued_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsQueued();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.CheckingDownload, true)]
|
||||
[InlineData(TorrentState.CheckingUpload, true)]
|
||||
[InlineData(TorrentState.CheckingResumeData, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsChecking_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsChecking();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.Allocating, true)]
|
||||
[InlineData(TorrentState.Downloading, false)]
|
||||
[InlineData(TorrentState.Uploading, false)]
|
||||
public void IsAllocating_ReturnsCorrectValue(TorrentState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsAllocating();
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TorrentState.FetchingMetadata, true)]
|
||||
[InlineData(TorrentState.ForcedFetchingMetadata, true)]
|
||||
@@ -400,7 +429,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { State = state };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsMetadataDownloading();
|
||||
@@ -415,7 +444,7 @@ public class QBitItemTests
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class QBitServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<QBitService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IQBittorrentClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public QBitServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<QBitService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IQBittorrentClientWrapper>();
|
||||
|
||||
// Setup default behavior for DryRunInterceptor to execute actions directly
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public QBitService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.qBittorrent,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = ""
|
||||
};
|
||||
|
||||
// Setup HTTP client provider
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new QBitService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
// Re-setup default DryRunInterceptor behavior
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of BlocklistProvider for testing purposes
|
||||
/// </summary>
|
||||
public static class TestBlocklistProviderFactory
|
||||
{
|
||||
public static BlocklistProvider Create()
|
||||
{
|
||||
var logger = new Mock<ILogger<BlocklistProvider>>().Object;
|
||||
var scopeFactory = new Mock<IServiceScopeFactory>().Object;
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
return new BlocklistProvider(logger, scopeFactory, cache);
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Shouldly;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionItemTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new TransmissionItem(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentInfo = new TorrentInfo { HashString = expectedHash };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = null };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentInfo = new TorrentInfo { Name = expectedName };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = null };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L, 1024L, 0.0)]
|
||||
[InlineData(512L, 1024L, 50.0)]
|
||||
[InlineData(768L, 1024L, 75.0)]
|
||||
[InlineData(1024L, 1024L, 100.0)]
|
||||
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
|
||||
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
|
||||
[InlineData(512L, null, 0.0)] // Edge case: null total size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
DownloadedEver = downloadedEver,
|
||||
TotalSize = totalSize
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Announce = "https://tracker2.example.com/announce" },
|
||||
new() { Announce = "udp://tracker3.example.com:1337/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker1.example.com:8080/announce" },
|
||||
new() { Announce = "https://tracker1.example.com/announce" },
|
||||
new() { Announce = "udp://tracker1.example.com:1337/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://valid.example.com/announce" },
|
||||
new() { Announce = "invalid-url" },
|
||||
new() { Announce = "" },
|
||||
new() { Announce = null }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[0]
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = null
|
||||
};
|
||||
var wrapper = new TransmissionItem(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Shouldly;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionItemWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new TransmissionItemWrapper(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHash = "test-hash-123";
|
||||
var torrentInfo = new TorrentInfo { HashString = expectedHash };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = null };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Hash;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var expectedName = "Test Torrent";
|
||||
var torrentInfo = new TorrentInfo { Name = expectedName };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_WithNullValue_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = null };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Name;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsPrivate;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Size;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L, 1024L, 0.0)]
|
||||
[InlineData(512L, 1024L, 50.0)]
|
||||
[InlineData(768L, 1024L, 75.0)]
|
||||
[InlineData(1024L, 1024L, 100.0)]
|
||||
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
|
||||
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
|
||||
[InlineData(512L, null, 0.0)] // Edge case: null total size
|
||||
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
DownloadedEver = downloadedEver,
|
||||
TotalSize = totalSize
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.CompletionPercentage;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void DownloadedBytes_ReturnsCorrectValue(long? downloadedEver, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { DownloadedEver = downloadedEver };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L, 512L, 2.0)] // Uploaded more than downloaded
|
||||
[InlineData(512L, 1024L, 0.5)] // Uploaded less than downloaded
|
||||
[InlineData(1024L, 1024L, 1.0)] // Equal
|
||||
[InlineData(0L, 1024L, 0.0)] // No upload
|
||||
[InlineData(1024L, 0L, 0.0)] // No download
|
||||
[InlineData(null, 1024L, 0.0)] // Null upload
|
||||
[InlineData(1024L, null, 0.0)] // Null download
|
||||
[InlineData(null, null, 0.0)] // Both null
|
||||
public void Ratio_ReturnsCorrectValue(long? uploadedEver, long? downloadedEver, double expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
UploadedEver = uploadedEver,
|
||||
DownloadedEver = downloadedEver
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Ratio;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3600L, 3600L)] // 1 hour
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(-1L, -1L)] // Unknown/infinite
|
||||
[InlineData(null, 0L)]
|
||||
public void Eta_ReturnsCorrectValue(long? eta, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Eta = eta };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(86400L, 86400L)] // 1 day
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(null, 0L)]
|
||||
public void SeedingTimeSeconds_ReturnsCorrectValue(long? secondsSeeding, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { SecondsSeeding = secondsSeeding };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = "abc123", Name = "Test Torrent" };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { HashString = "abc123", Name = "Test Torrent" };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
var ignoredDownloads = new[] { "abc123" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingCategory_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
HashString = "abc123",
|
||||
Name = "Test Torrent",
|
||||
DownloadDir = "/downloads/test-category"
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
var ignoredDownloads = new[] { "test-category" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTracker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
HashString = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker.example.com/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
var ignoredDownloads = new[] { "tracker.example.com" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_NotMatching_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
HashString = "abc123",
|
||||
Name = "Test Torrent",
|
||||
Labels = new[] { "some-category" },
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker.example.com/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
var ignoredDownloads = new[] { "notmatching" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixture>
|
||||
{
|
||||
private readonly TransmissionServiceFixture _fixture;
|
||||
|
||||
public TransmissionServiceDCTests(TransmissionServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class GetSeedingDownloads_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public GetSeedingDownloads_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiltersStatus5And6()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { HashString = "hash1", Name = "Torrent 1", Status = 5 }, // Seeding
|
||||
new TorrentInfo { HashString = "hash2", Name = "Torrent 2", Status = 4 }, // Downloading
|
||||
new TorrentInfo { HashString = "hash3", Name = "Torrent 3", Status = 6 } // Seeding
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, item => Assert.NotNull(item.Hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { HashString = "", Name = "No Hash", Status = 5 },
|
||||
new TorrentInfo { HashString = "hash1", Name = "Valid Hash", Status = 5 }
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenTorrentsNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = null
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToBeCleanedAsync_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToBeCleanedAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }),
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" }),
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash3", DownloadDir = "/downloads/music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, x => x.Category == "movies");
|
||||
Assert.Contains(result, x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmptyList_WhenNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToChangeCategoryAsync_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToChangeCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" }),
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/tv" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsDownloadsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "", DownloadDir = "/downloads/movies" }),
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCategoryAsync_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public CreateCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsNoOp()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("new-category");
|
||||
|
||||
// Assert - no exceptions thrown, no client calls made
|
||||
_fixture.ClientWrapper.VerifyNoOtherCalls();
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteDownload_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public DeleteDownload_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetsIdFromHash_ThenDeletes()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "test-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { Id = 123, HashString = hash }
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlesNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "nonexistent-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert - no exception thrown
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), It.IsAny<bool>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletesWithData()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "test-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { Id = 123, HashString = hash }
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeCategoryForNoHardLinksAsync_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public ChangeCategoryForNoHardLinksAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingHash_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "", Name = "Test", DownloadDir = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingName_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "", DownloadDir = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingDownloadDir_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingFiles_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", Name = "Test", DownloadDir = "/downloads", Files = null })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingFileStats_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads",
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = null
|
||||
})
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoHardlinks_ChangesLocation()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = baseDownloadDir,
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasHardlinks_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads/movies",
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileNotFound_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads/movies",
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnwantedFiles_IgnoredInCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads/movies",
|
||||
Files = new[]
|
||||
{
|
||||
new TransmissionTorrentFiles { Name = "file1.mkv" },
|
||||
new TransmissionTorrentFiles { Name = "file2.mkv" }
|
||||
},
|
||||
FileStats = new[]
|
||||
{
|
||||
new TransmissionTorrentFileStats { Wanted = false },
|
||||
new TransmissionTorrentFileStats { Wanted = true }
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithIgnoredRootDir_PopulatesFileCounts()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked",
|
||||
UnlinkedIgnoredRootDirs = ["/ignore"]
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = "/downloads/movies",
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishesCategoryChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = baseDownloadDir,
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - EventPublisher is not mocked, so we just verify the method completed
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendsTargetCategoryToBasePath()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies", "subfolder");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine(baseDownloadDir, "unlinked").Split(['\\', '/']));
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
Id = 123,
|
||||
HashString = "hash1",
|
||||
Name = "Test",
|
||||
DownloadDir = baseDownloadDir,
|
||||
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<TransmissionService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<ITransmissionClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public TransmissionServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<TransmissionService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<ITransmissionClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public TransmissionService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.Transmission,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:9091"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = "/transmission"
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new TransmissionService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Moq;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture>
|
||||
{
|
||||
private readonly TransmissionServiceFixture _fixture;
|
||||
|
||||
public TransmissionServiceTests(TransmissionServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_BasicScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentNotFound_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "nonexistent";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = true,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFilesUnwanted_DeletesFromClient()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
FileStats = new[]
|
||||
{
|
||||
new TransmissionTorrentFileStats { Wanted = false },
|
||||
new TransmissionTorrentFileStats { Wanted = false }
|
||||
}
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SomeFilesWanted_DoesNotRemove()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
RateDownload = 1000,
|
||||
FileStats = new[]
|
||||
{
|
||||
new TransmissionTorrentFileStats { Wanted = false },
|
||||
new TransmissionTorrentFileStats { Wanted = true }
|
||||
}
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string category = "test-category";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
Labels = new[] { category },
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_MissingFileStatsScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_MissingFileStatsScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilesWithMissingWantedStatus_DoesNotRemove()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
RateDownload = 1000,
|
||||
FileStats = new[]
|
||||
{
|
||||
new TransmissionTorrentFileStats { Wanted = null },
|
||||
new TransmissionTorrentFileStats { Wanted = false }
|
||||
}
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotDownloadingState_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 6,
|
||||
IsPrivate = false,
|
||||
RateDownload = 0,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
RateDownload = 0,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : TransmissionServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
IsPrivate = false,
|
||||
RateDownload = 1000,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Id = 1,
|
||||
HashString = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 4,
|
||||
RateDownload = 0,
|
||||
Eta = 0,
|
||||
IsPrivate = false,
|
||||
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[] { torrentInfo }
|
||||
};
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,95 +109,173 @@ public class UTorrentItemWrapperTests
|
||||
result.ShouldBe(expectedPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithValidUrls_ReturnsHostNames()
|
||||
[Theory]
|
||||
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
|
||||
[InlineData(0L, 0L)]
|
||||
public void DownloadedBytes_ReturnsCorrectValue(long downloaded, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker2.example.com/announce\r\nudp://tracker3.example.com:1337/announce"
|
||||
};
|
||||
var torrentItem = new UTorrentItem { Downloaded = downloaded };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.DownloadedBytes;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
result.ShouldContain("tracker2.example.com");
|
||||
result.ShouldContain("tracker3.example.com");
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2000, 2.0)] // 2000 permille = 2.0 ratio
|
||||
[InlineData(500, 0.5)] // 500 permille = 0.5 ratio
|
||||
[InlineData(1000, 1.0)] // 1000 permille = 1.0 ratio
|
||||
[InlineData(0, 0.0)] // No ratio
|
||||
public void Ratio_ReturnsCorrectValue(int ratioRaw, double expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { RatioRaw = ratioRaw };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Ratio;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3600, 3600L)] // 1 hour
|
||||
[InlineData(0, 0L)]
|
||||
[InlineData(-1, -1L)] // Unknown/infinite
|
||||
public void Eta_ReturnsCorrectValue(int eta, long expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { ETA = eta };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Eta;
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
|
||||
public void SeedingTimeSeconds_WithCompletedDate_ReturnsPositiveValue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker1.example.com/announce\r\nudp://tracker1.example.com:1337/announce"
|
||||
};
|
||||
// Arrange - Set DateCompleted to 1 hour ago
|
||||
var oneHourAgo = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
|
||||
var torrentItem = new UTorrentItem { DateCompleted = oneHourAgo };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker1.example.com");
|
||||
// Assert - Should be approximately 3600 seconds (1 hour), allow some tolerance
|
||||
result.ShouldBeInRange(3599L, 3601L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
|
||||
public void SeedingTimeSeconds_WithNoCompletedDate_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://valid.example.com/announce\r\ninvalid-url\r\n\r\n "
|
||||
};
|
||||
// Arrange - DateCompleted = 0 means not completed
|
||||
var torrentItem = new UTorrentItem { DateCompleted = 0 };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.SeedingTimeSeconds;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("valid.example.com");
|
||||
result.ShouldBe(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithEmptyList_ReturnsEmptyList()
|
||||
public void IsIgnored_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = ""
|
||||
};
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.IsIgnored(Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trackers_WithNullTrackerList_ReturnsEmptyList()
|
||||
public void IsIgnored_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem();
|
||||
var torrentProperties = new UTorrentProperties(); // Trackers defaults to empty string
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
var ignoredDownloads = new[] { "abc123" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.Trackers;
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingCategory_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent", Label = "test-category" };
|
||||
var torrentProperties = new UTorrentProperties();
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
var ignoredDownloads = new[] { "test-category" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingTracker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker.example.com/announce"
|
||||
};
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
var ignoredDownloads = new[] { "tracker.example.com" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_NotMatching_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent", Label = "some-category" };
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Trackers = "http://tracker.example.com/announce"
|
||||
};
|
||||
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
|
||||
var ignoredDownloads = new[] { "notmatching" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
{
|
||||
private readonly UTorrentServiceFixture _fixture;
|
||||
|
||||
public UTorrentServiceDCTests(UTorrentServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class GetSeedingDownloads_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public GetSeedingDownloads_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiltersSeedingTorrents()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new List<UTorrentItem>
|
||||
{
|
||||
new UTorrentItem { Hash = "hash1", Name = "Torrent 1", Status = 9, DateCompleted = 1000 }, // Seeding (Started + Checked, DateCompleted > 0)
|
||||
new UTorrentItem { Hash = "hash2", Name = "Torrent 2", Status = 9, DateCompleted = 0 }, // Downloading (Started + Checked, DateCompleted = 0)
|
||||
new UTorrentItem { Hash = "hash3", Name = "Torrent 3", Status = 9, DateCompleted = 2000 } // Seeding (Started + Checked, DateCompleted > 0)
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash3"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" });
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenNoSeedingTorrents()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new List<UTorrentItem>
|
||||
{
|
||||
new UTorrentItem { Hash = "hash1", Name = "Torrent 1", Status = 9 } // Not seeding
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrents = new List<UTorrentItem>
|
||||
{
|
||||
new UTorrentItem { Hash = "", Name = "No Hash", Status = 9, DateCompleted = 1000 },
|
||||
new UTorrentItem { Hash = "hash1", Name = "Valid Hash", Status = 9, DateCompleted = 1000 }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToBeCleanedAsync_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToBeCleanedAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }),
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash2", Label = "tv" }, new UTorrentProperties { Hash = "hash2", Pex = 1, Trackers = "" }),
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash3", Label = "music" }, new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, x => x.Category == "movies");
|
||||
Assert.Contains(result, x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmptyList_WhenNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "music" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToChangeCategoryAsync_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToChangeCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }),
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash2", Label = "tv" }, new UTorrentProperties { Hash = "hash2", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsDownloadsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "", Label = "movies" }, new UTorrentProperties { Hash = "", Pex = 1, Trackers = "" }),
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCategoryAsync_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public CreateCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsNoOp()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("new-category");
|
||||
|
||||
// Assert - no exceptions thrown, no client calls made
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteDownload_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public DeleteDownload_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsClientDelete()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizesHashToLowercase()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "UPPERCASE-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsClientDeleteWithoutSourceFiles()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeCategoryForNoHardLinksAsync_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public ChangeCategoryForNoHardLinksAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingHash_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingName_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingCategory_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoHardlinks_ChangesLabel()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasHardlinks_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileNotFound_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkippedFiles_IgnoredInCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
|
||||
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithIgnoredRootDir_PopulatesFileCounts()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked",
|
||||
UnlinkedIgnoredRootDirs = ["/ignore"]
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishesCategoryChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - EventPublisher is not mocked, so we just verify the method completed
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullFilesResponse_ChangesLabel()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync((List<UTorrentFile>?)null);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - When files is null, it uses empty collection and proceeds to change label
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync("hash1", "unlinked"), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class UTorrentServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<UTorrentService>> Logger { get; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public BlocklistProvider BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IUTorrentClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public UTorrentServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<UTorrentService>>();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = TestBlocklistProviderFactory.Create();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IUTorrentClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public UTorrentService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.uTorrent,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = "/gui/"
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new UTorrentService(
|
||||
Logger.Object,
|
||||
Cache,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cache.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
{
|
||||
private readonly UTorrentServiceFixture _fixture;
|
||||
|
||||
public UTorrentServiceTests(UTorrentServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_BasicScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentNotFound_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "nonexistent";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync((UTorrentItem?)null);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked = 1 + 8
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = -1, // -1 means private torrent
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked = 1 + 8
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1, // 1 means public torrent (PEX enabled)
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFilesUnwanted_DeletesFromClient()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
|
||||
new UTorrentFile { Name = "file2.mkv", Priority = 0, Index = 1, Size = 2000, Downloaded = 0 }
|
||||
});
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SomeFilesWanted_DoesNotRemove()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
|
||||
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string category = "test-category";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000,
|
||||
Label = category
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIgnoredByTrackerDomain_ReturnsEmptyResult()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
const string trackerDomain = "tracker.example.com";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = $"https://{trackerDomain}/announce\r\n"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_ExceptionHandlingScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_ExceptionHandlingScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesAsync_ThrowsException_ContinuesProcessing()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9,
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotDownloadingState_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 32, // Paused
|
||||
DownloadSpeed = 0
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked
|
||||
DownloadSpeed = 0
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : UTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked
|
||||
DownloadSpeed = 1000
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var torrentItem = new UTorrentItem
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
Status = 9, // Started + Checked
|
||||
DownloadSpeed = 0,
|
||||
ETA = 0
|
||||
};
|
||||
|
||||
var torrentProperties = new UTorrentProperties
|
||||
{
|
||||
Hash = hash,
|
||||
Pex = 1,
|
||||
Trackers = ""
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter.Consumers;
|
||||
|
||||
public class DownloadHunterConsumerTests
|
||||
{
|
||||
private readonly Mock<ILogger<DownloadHunterConsumer<SearchItem>>> _loggerMock;
|
||||
private readonly Mock<IDownloadHunter> _downloadHunterMock;
|
||||
private readonly DownloadHunterConsumer<SearchItem> _consumer;
|
||||
|
||||
public DownloadHunterConsumerTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DownloadHunterConsumer<SearchItem>>>();
|
||||
_downloadHunterMock = new Mock<IDownloadHunter>();
|
||||
_consumer = new DownloadHunterConsumer<SearchItem>(_loggerMock.Object, _downloadHunterMock.Object);
|
||||
}
|
||||
|
||||
#region Consume Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_CallsHuntDownloadsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(request), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WhenHunterThrows_LogsErrorAndDoesNotRethrow()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.ThrowsAsync(new Exception("Hunt failed"));
|
||||
|
||||
// Act - Should not throw
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to search for replacement")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_PassesCorrectRequestToHunter()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
DownloadHuntRequest<SearchItem>? capturedRequest = null;
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Callback<DownloadHuntRequest<SearchItem>>(r => capturedRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Lidarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 999 },
|
||||
Record = CreateQueueRecord()
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(
|
||||
It.Is<DownloadHuntRequest<SearchItem>>(r => r.InstanceType == InstanceType.Lidarr)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static DownloadHuntRequest<SearchItem> CreateHuntRequest()
|
||||
{
|
||||
return new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord()
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://radarr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123"
|
||||
};
|
||||
}
|
||||
|
||||
private static Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>> CreateConsumeContextMock(DownloadHuntRequest<SearchItem> message)
|
||||
{
|
||||
var mock = new Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>>();
|
||||
mock.Setup(c => c.Message).Returns(message);
|
||||
return mock;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter;
|
||||
|
||||
public class DownloadHunterTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
|
||||
private readonly Mock<IArrClient> _arrClientMock;
|
||||
private readonly FakeTimeProvider _fakeTimeProvider;
|
||||
private readonly Infrastructure.Features.DownloadHunter.DownloadHunter _downloadHunter;
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public DownloadHunterTests()
|
||||
{
|
||||
// Use SQLite in-memory with shared connection to support complex types
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_dataContext = new DataContext(options);
|
||||
_dataContext.Database.EnsureCreated();
|
||||
|
||||
_arrClientFactoryMock = new Mock<IArrClientFactory>();
|
||||
_arrClientMock = new Mock<IArrClient>();
|
||||
_fakeTimeProvider = new FakeTimeProvider();
|
||||
|
||||
_arrClientFactoryMock
|
||||
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(_arrClientMock.Object);
|
||||
|
||||
_downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter(
|
||||
_dataContext,
|
||||
_arrClientFactoryMock.Object,
|
||||
_fakeTimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
#region HuntDownloadsAsync - Search Disabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchDisabled_DoesNotCallArrClient()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: false);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
await _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>()), Times.Never);
|
||||
_arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchDisabled_ReturnsImmediately()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: false);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Should complete without needing to advance time
|
||||
var completedTask = await Task.WhenAny(task, Task.Delay(100));
|
||||
Assert.Same(task, completedTask);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HuntDownloadsAsync - Search Enabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsArrClientFactory()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act - Start the task and advance time
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsSearchItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(
|
||||
c => c.SearchItemsAsync(
|
||||
request.Instance,
|
||||
It.Is<HashSet<SearchItem>>(s => s.Contains(request.SearchItem))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public async Task HuntDownloadsAsync_UsesCorrectInstanceType(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest(instanceType);
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HuntDownloadsAsync - Delay Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WaitsForConfiguredDelay()
|
||||
{
|
||||
// Arrange
|
||||
const ushort configuredDelay = 120;
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: configuredDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Task should not complete before advancing time
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance partial time - should still not complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(configuredDelay - 1));
|
||||
await Task.Delay(10); // Give the task a chance to complete if it would
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance remaining time - should now complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayBelowMinimum_UsesDefaultDelay()
|
||||
{
|
||||
// Arrange - Set delay below minimum (simulating manual DB edit)
|
||||
const ushort belowMinDelay = 10; // Below MinSearchDelaySeconds (60)
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: belowMinDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by the below-min value - should NOT complete because it should use default
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(belowMinDelay));
|
||||
await Task.Delay(10);
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance to default delay - should now complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds - belowMinDelay));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayIsZero_UsesDefaultDelay()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: 0);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Should not complete immediately
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance to default delay
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayAtMinimum_UsesConfiguredDelay()
|
||||
{
|
||||
// Arrange - Set delay exactly at minimum
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by minimum - should complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayAboveMinimum_UsesConfiguredDelay()
|
||||
{
|
||||
// Arrange - Set delay above minimum
|
||||
const ushort aboveMinDelay = 180;
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: aboveMinDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by minimum - should NOT complete yet
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await Task.Delay(10);
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance remaining time
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(aboveMinDelay - Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task SetupGeneralConfig(bool searchEnabled, ushort searchDelay = Constants.DefaultSearchDelaySeconds)
|
||||
{
|
||||
var generalConfig = new GeneralConfig
|
||||
{
|
||||
SearchEnabled = searchEnabled,
|
||||
SearchDelay = searchDelay
|
||||
};
|
||||
|
||||
_dataContext.GeneralConfigs.Add(generalConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static DownloadHuntRequest<SearchItem> CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr)
|
||||
{
|
||||
return new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord()
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://arr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover.Consumers;
|
||||
|
||||
public class DownloadRemoverConsumerTests
|
||||
{
|
||||
private readonly Mock<ILogger<DownloadRemoverConsumer<SearchItem>>> _loggerMock;
|
||||
private readonly Mock<IQueueItemRemover> _queueItemRemoverMock;
|
||||
private readonly DownloadRemoverConsumer<SearchItem> _consumer;
|
||||
|
||||
public DownloadRemoverConsumerTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DownloadRemoverConsumer<SearchItem>>>();
|
||||
_queueItemRemoverMock = new Mock<IQueueItemRemover>();
|
||||
_consumer = new DownloadRemoverConsumer<SearchItem>(_loggerMock.Object, _queueItemRemoverMock.Object);
|
||||
}
|
||||
|
||||
#region Consume Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_CallsRemoveQueueItemAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(request), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WhenRemoverThrows_LogsErrorAndDoesNotRethrow()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.ThrowsAsync(new Exception("Remove failed"));
|
||||
|
||||
// Act - Should not throw
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to remove queue item")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_PassesCorrectRequestToRemover()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
QueueItemRemoveRequest<SearchItem>? capturedRequest = null;
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Callback<QueueItemRemoveRequest<SearchItem>>(r => capturedRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
Assert.Equal(request.RemoveFromClient, capturedRequest.RemoveFromClient);
|
||||
Assert.Equal(request.DeleteReason, capturedRequest.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithRemoveFromClientTrue_PassesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Sonarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 456 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.Stalled
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(req =>
|
||||
req.RemoveFromClient == true &&
|
||||
req.DeleteReason == DeleteReason.Stalled)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithDifferentDeleteReasons_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 789 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = false,
|
||||
DeleteReason = DeleteReason.FailedImport
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(req =>
|
||||
req.DeleteReason == DeleteReason.FailedImport)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Readarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 111 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.SlowSpeed
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_queueItemRemoverMock
|
||||
.Setup(r => r.RemoveQueueItemAsync(It.IsAny<QueueItemRemoveRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync(
|
||||
It.Is<QueueItemRemoveRequest<SearchItem>>(req => req.InstanceType == InstanceType.Readarr)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest()
|
||||
{
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = true,
|
||||
DeleteReason = DeleteReason.Stalled
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://radarr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123"
|
||||
};
|
||||
}
|
||||
|
||||
private static Mock<ConsumeContext<QueueItemRemoveRequest<SearchItem>>> CreateConsumeContextMock(QueueItemRemoveRequest<SearchItem> message)
|
||||
{
|
||||
var mock = new Mock<ConsumeContext<QueueItemRemoveRequest<SearchItem>>>();
|
||||
mock.Setup(c => c.Message).Returns(message);
|
||||
return mock;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
|
||||
|
||||
public class QueueItemRemoverTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
|
||||
private readonly Mock<IBus> _busMock;
|
||||
private readonly MemoryCache _memoryCache;
|
||||
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
|
||||
private readonly Mock<IArrClient> _arrClientMock;
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly QueueItemRemover _queueItemRemover;
|
||||
|
||||
public QueueItemRemoverTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
|
||||
_busMock = new Mock<IBus>();
|
||||
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
|
||||
_arrClientFactoryMock = new Mock<IArrClientFactory>();
|
||||
_arrClientMock = new Mock<IArrClient>();
|
||||
|
||||
_arrClientFactoryMock
|
||||
.Setup(f => f.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(_arrClientMock.Object);
|
||||
|
||||
// Create real EventPublisher with mocked dependencies
|
||||
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_eventsContext = new EventsContext(eventsContextOptions);
|
||||
|
||||
var hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
|
||||
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
// Setup interceptor to execute the action with params using DynamicInvoke
|
||||
dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
var result = action.DynamicInvoke(parameters);
|
||||
if (result is Task task)
|
||||
{
|
||||
return task;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
_eventPublisher = new EventPublisher(
|
||||
_eventsContext,
|
||||
hubContextMock.Object,
|
||||
Mock.Of<ILogger<EventPublisher>>(),
|
||||
Mock.Of<INotificationPublisher>(),
|
||||
dryRunInterceptorMock.Object);
|
||||
|
||||
_queueItemRemover = new QueueItemRemover(
|
||||
_loggerMock.Object,
|
||||
_busMock.Object,
|
||||
_memoryCache,
|
||||
_arrClientFactoryMock.Object,
|
||||
_eventPublisher
|
||||
);
|
||||
|
||||
// Clear static RecurringHashes before each test
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_memoryCache.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
#region RemoveQueueItemAsync - Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Success_DeletesQueueItem()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
request.Instance,
|
||||
request.Record,
|
||||
request.RemoveFromClient,
|
||||
request.DeleteReason), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
DownloadHuntRequest<SearchItem>? capturedRequest = null;
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_busMock
|
||||
.Setup(b => b.Publish(It.IsAny<DownloadHuntRequest<SearchItem>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<DownloadHuntRequest<SearchItem>, CancellationToken>((r, _) => capturedRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest!.InstanceType);
|
||||
Assert.Equal(request.Instance, capturedRequest.Instance);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Success_ClearsDownloadMarkedForRemovalCache()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
|
||||
_memoryCache.Set(cacheKey, true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public async Task RemoveQueueItemAsync_UsesCorrectClientForInstanceType(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(instanceType);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - Recurring Hash Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
Striker.RecurringHashes.TryAdd(hash, null);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_RemovesHashFromRecurring()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
Striker.RecurringHashes.TryAdd(hash, null);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - HTTP Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenNotFoundError_ThrowsWithItemAlreadyDeletedMessage()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<Exception>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Contains("might have already been deleted", exception.Message);
|
||||
Assert.Contains(request.InstanceType.ToString(), exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenNotFoundError_ClearsCacheInFinally()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
|
||||
_memoryCache.Set(cacheKey, true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
// Cache should be cleared in finally block
|
||||
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenOtherHttpError_Rethrows()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var originalException = new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.ThrowsAsync(originalException);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Same(originalException, exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenNonHttpError_Rethrows()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
var originalException = new InvalidOperationException("Some other error");
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.ThrowsAsync(originalException);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _queueItemRemover.RemoveQueueItemAsync(request));
|
||||
|
||||
Assert.Same(originalException, exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - Delete Reason Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(DeleteReason.Stalled)]
|
||||
[InlineData(DeleteReason.FailedImport)]
|
||||
[InlineData(DeleteReason.SlowSpeed)]
|
||||
[InlineData(DeleteReason.SlowTime)]
|
||||
[InlineData(DeleteReason.DownloadingMetadata)]
|
||||
[InlineData(DeleteReason.MalwareFileFound)]
|
||||
public async Task RemoveQueueItemAsync_PassesCorrectDeleteReason(DeleteReason deleteReason)
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(deleteReason: deleteReason);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
deleteReason), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RemoveQueueItemAsync_PassesCorrectRemoveFromClientFlag(bool removeFromClient)
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(removeFromClient: removeFromClient);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
removeFromClient,
|
||||
It.IsAny<DeleteReason>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
|
||||
InstanceType instanceType = InstanceType.Sonarr,
|
||||
bool removeFromClient = true,
|
||||
DeleteReason deleteReason = DeleteReason.Stalled)
|
||||
{
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://arr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123DEF456"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
|
||||
|
||||
[Collection(JobHandlerCollection.Name)]
|
||||
public class DownloadCleanerTests : IDisposable
|
||||
{
|
||||
private readonly JobHandlerFixture _fixture;
|
||||
private readonly Mock<ILogger<DownloadCleaner>> _logger;
|
||||
|
||||
public DownloadCleanerTests(JobHandlerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.RecreateDataContext();
|
||||
_fixture.ResetMocks();
|
||||
_logger = _fixture.CreateLogger<DownloadCleaner>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private DownloadCleaner CreateSut()
|
||||
{
|
||||
return new DownloadCleaner(
|
||||
_logger.Object,
|
||||
_fixture.DataContext,
|
||||
_fixture.Cache,
|
||||
_fixture.MessageBus.Object,
|
||||
_fixture.ArrClientFactory.Object,
|
||||
_fixture.ArrQueueIterator.Object,
|
||||
_fixture.DownloadServiceFactory.Object,
|
||||
_fixture.EventPublisher.Object,
|
||||
_fixture.TimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the handler and advances time past the 10-second delay
|
||||
/// </summary>
|
||||
private async Task ExecuteWithTimeAdvance(DownloadCleaner sut)
|
||||
{
|
||||
var task = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
|
||||
await task;
|
||||
}
|
||||
|
||||
#region ExecuteAsync Tests (inherited from GenericHandler)
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_LoadsAllConfigsIntoContextProvider()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - verify configs were loaded (by checking the handler completed without errors)
|
||||
// The configs are loaded into ContextProvider which is AsyncLocal scoped
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no download clients")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteInternalAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("no download clients are configured")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoFeaturesEnabled_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - should warn about no seeding downloads or no features enabled
|
||||
// The exact message depends on the order of checks
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
It.IsAny<LogLevel>(),
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.AtLeastOnce
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoSeedingDownloadsFound_LogsInfoAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No seeding downloads found")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_FiltersOutIgnoredDownloads()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
// Add ignored download to general config
|
||||
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
|
||||
generalConfig.IgnoredDownloads = ["ignored-hash"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("ignored-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Ignored Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(true);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert - the download should be skipped
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("download is ignored")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_FiltersOutDownloadsUsedByArrs()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("arr-download-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Arr Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
// Setup arr client to return queue record with matching download ID
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "arr-download-hash",
|
||||
Title = "Test Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert - the download should be skipped because it's used by an arr
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("download is used by an arr")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_ProcessesAllArrConfigs()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
// Need at least one download for arr processing to occur
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert - both instances should be processed
|
||||
_fixture.ArrClientFactory.Verify(
|
||||
x => x.GetClient(InstanceType.Sonarr),
|
||||
Times.Once
|
||||
);
|
||||
_fixture.ArrClientFactory.Verify(
|
||||
x => x.GetClient(InstanceType.Radarr),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChangeUnlinkedCategoriesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenUnlinkedEnabled_EvaluatesDownloadsForHardlinks()
|
||||
{
|
||||
// Arrange
|
||||
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
|
||||
downloadCleanerConfig.UnlinkedEnabled = true;
|
||||
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
|
||||
downloadCleanerConfig.UnlinkedCategories = ["completed"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockDownloadService
|
||||
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("hardlinks")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CleanDownloadsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenCategoriesConfigured_EvaluatesDownloadsForCleaning()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext, "completed", 1.0, 60);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CleanDownloadsAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Evaluating") && v.ToString()!.Contains("cleanup")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ProcessInstanceAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_CollectsDownloadIdsFromArrQueue()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
// Need at least one download for arr processing to occur
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecords = new List<QueueRecord>
|
||||
{
|
||||
new() { Id = 1, DownloadId = "hash1", Title = "Download 1", Protocol = "torrent" },
|
||||
new() { Id = 2, DownloadId = "hash2", Title = "Download 2", Protocol = "torrent" }
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
mockArrClient.Object,
|
||||
It.Is<ArrInstance>(i => i.Id == sonarrInstance.Id),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback(queueRecords);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert - verify the iterator was called
|
||||
_fixture.ArrQueueIterator.Verify(
|
||||
x => x.Iterate(
|
||||
mockArrClient.Object,
|
||||
It.Is<ArrInstance>(i => i.Id == sonarrInstance.Id),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenDownloadServiceFails_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Failing Client");
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Working Client");
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var failingService = _fixture.CreateMockDownloadService("Failing Client");
|
||||
failingService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ThrowsAsync(new Exception("Connection failed"));
|
||||
|
||||
var workingService = _fixture.CreateMockDownloadService("Working Client");
|
||||
workingService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var callCount = 0;
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
callCount++;
|
||||
return callCount == 1 ? failingService.Object : workingService.Object;
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to get seeding downloads")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeUnlinkedCategoriesAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
|
||||
downloadCleanerConfig.UnlinkedEnabled = true;
|
||||
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
|
||||
downloadCleanerConfig.UnlinkedCategories = ["completed"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.Throws(new Exception("Filter failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for hardlinks evaluation")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeUnlinkedCategoriesAsync_WhenCreateCategoryThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
|
||||
downloadCleanerConfig.UnlinkedEnabled = true;
|
||||
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
|
||||
downloadCleanerConfig.UnlinkedCategories = ["completed"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new Exception("Create category failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to create category")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeUnlinkedCategoriesAsync_WhenChangeCategoryThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var downloadCleanerConfig = _fixture.DataContext.DownloadCleanerConfigs.First();
|
||||
downloadCleanerConfig.UnlinkedEnabled = true;
|
||||
downloadCleanerConfig.UnlinkedTargetCategory = "unlinked";
|
||||
downloadCleanerConfig.UnlinkedCategories = ["completed"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToChangeCategoryAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CreateCategoryAsync(It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mockDownloadService
|
||||
.Setup(x => x.ChangeCategoryForNoHardLinksAsync(It.IsAny<List<ITorrentItemWrapper>>()))
|
||||
.ThrowsAsync(new Exception("Change category failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to change category for download client")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanDownloadsAsync_WhenFilterDownloadsThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Throws(new Exception("Filter failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to filter downloads for cleaning")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanDownloadsAsync_WhenCleanDownloadsThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.CleanDownloadsAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.ThrowsAsync(new Exception("Clean failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await ExecuteWithTimeAdvance(sut);
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to clean downloads for download client")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessArrConfigAsync_WhenArrIteratorThrows_LogsErrorAndRethrows()
|
||||
{
|
||||
// Arrange - DownloadCleaner calls ProcessArrConfigAsync with throwOnFailure=true
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
|
||||
mockTorrent.Setup(x => x.Name).Returns("Test Download");
|
||||
mockTorrent.Setup(x => x.IsIgnored(It.IsAny<List<string>>())).Returns(false);
|
||||
mockTorrent.Setup(x => x.Category).Returns("completed");
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.GetSeedingDownloads())
|
||||
.ReturnsAsync([mockTorrent.Object]);
|
||||
mockDownloadService
|
||||
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
|
||||
It.IsAny<List<ITorrentItemWrapper>>(),
|
||||
It.IsAny<List<SeedingRule>>()
|
||||
))
|
||||
.Returns([]);
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
// Make the arr queue iterator throw an exception
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.ThrowsAsync(new InvalidOperationException("Arr connection failed"));
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act & Assert - exception should propagate since throwOnFailure=true
|
||||
// Need to advance time for the delay to pass before the exception is thrown
|
||||
var task = sut.ExecuteAsync();
|
||||
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => task);
|
||||
Assert.Equal("Arr connection failed", exception.Message);
|
||||
|
||||
// Verify error was logged
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to process")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using MalwareBlockerJob = Cleanuparr.Infrastructure.Features.Jobs.MalwareBlocker;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
|
||||
|
||||
[Collection(JobHandlerCollection.Name)]
|
||||
public class MalwareBlockerTests : IDisposable
|
||||
{
|
||||
private readonly JobHandlerFixture _fixture;
|
||||
private readonly Mock<ILogger<MalwareBlockerJob>> _logger;
|
||||
|
||||
public MalwareBlockerTests(JobHandlerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.RecreateDataContext();
|
||||
_fixture.ResetMocks();
|
||||
_logger = _fixture.CreateLogger<MalwareBlockerJob>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private MalwareBlockerJob CreateSut()
|
||||
{
|
||||
return new MalwareBlockerJob(
|
||||
_logger.Object,
|
||||
_fixture.DataContext,
|
||||
_fixture.Cache,
|
||||
_fixture.MessageBus.Object,
|
||||
_fixture.ArrClientFactory.Object,
|
||||
_fixture.ArrQueueIterator.Object,
|
||||
_fixture.DownloadServiceFactory.Object,
|
||||
_fixture.BlocklistProvider.Object,
|
||||
_fixture.EventPublisher.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region ExecuteInternalAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoDownloadClientsConfigured_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No download clients configured")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenNoBlocklistsEnabled_LogsWarningAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No blocklists are enabled")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenBlocklistEnabled_LoadsBlocklists()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_fixture.BlocklistProvider.Verify(x => x.LoadBlocklistsAsync(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenSonarrEnabled_ProcessesSonarrInstances()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenDeleteKnownMalwareEnabled_ProcessesAllArrs()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.DeleteKnownMalware = true;
|
||||
// Need at least one blocklist enabled for processing to occur
|
||||
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - Sonarr and Radarr processed because DeleteKnownMalware is true
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr), Times.Once);
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ProcessInstanceAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_SkipsIgnoredDownloads()
|
||||
{
|
||||
// Arrange
|
||||
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
|
||||
generalConfig.IgnoredDownloads = ["ignored-download-id"];
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "ignored-download-id",
|
||||
Title = "Ignored Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ignored")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_ChecksTorrentClientsForBlockedFiles()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "torrent-download-id",
|
||||
Title = "Torrent Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult { Found = true, ShouldRemove = false });
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
mockDownloadService.Verify(
|
||||
x => x.BlockUnwantedFilesAsync("torrent-download-id", It.IsAny<List<string>>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenShouldRemove_PublishesRemoveRequest()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "malware-download-id",
|
||||
Title = "Malware Download",
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.DeleteReason == DeleteReason.AllFilesBlocked
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenPrivateAndDeletePrivateFalse_DoesNotRemoveFromClient()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
// Ensure DeletePrivate is false
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.DeletePrivate = false;
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "private-malware-id",
|
||||
Title = "Private Malware",
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = true,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - RemoveFromClient should be false
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.RemoveFromClient == false
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenDownloadNotFoundInTorrentClient_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "missing-download-id",
|
||||
Title = "Missing Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult { Found = false });
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Download not found in any torrent client")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenDownloadServiceThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "error-download-id",
|
||||
Title = "Error Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ThrowsAsync(new Exception("Connection failed"));
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error checking download")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void EnableSonarrBlocklist()
|
||||
{
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
|
||||
_fixture.DataContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for job handler tests that share <see cref="JobHandlerFixture"/>.
|
||||
/// Tests in this collection run sequentially to avoid FakeTimeProvider interference.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public class JobHandlerCollection : ICollectionFixture<JobHandlerFixture>
|
||||
{
|
||||
public const string Name = "JobHandler";
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Persistence;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Base fixture for job handler tests providing common mock dependencies
|
||||
/// </summary>
|
||||
public class JobHandlerFixture : IDisposable
|
||||
{
|
||||
public DataContext DataContext { get; private set; }
|
||||
public MemoryCache Cache { get; }
|
||||
public Mock<IBus> MessageBus { get; }
|
||||
public Mock<IArrClientFactory> ArrClientFactory { get; }
|
||||
public Mock<IArrQueueIterator> ArrQueueIterator { get; }
|
||||
public Mock<IDownloadServiceFactory> DownloadServiceFactory { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public Mock<IBlocklistProvider> BlocklistProvider { get; }
|
||||
public FakeTimeProvider TimeProvider { get; private set; }
|
||||
|
||||
public JobHandlerFixture()
|
||||
{
|
||||
DataContext = TestDataContextFactory.Create();
|
||||
Cache = new MemoryCache(new MemoryCacheOptions());
|
||||
MessageBus = new Mock<IBus>();
|
||||
ArrClientFactory = new Mock<IArrClientFactory>();
|
||||
ArrQueueIterator = new Mock<IArrQueueIterator>();
|
||||
DownloadServiceFactory = new Mock<IDownloadServiceFactory>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = new Mock<IBlocklistProvider>();
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
|
||||
// Setup default behaviors
|
||||
SetupDefaultBehaviors();
|
||||
}
|
||||
|
||||
private void SetupDefaultBehaviors()
|
||||
{
|
||||
// EventPublisher methods return completed task by default
|
||||
EventPublisher
|
||||
.Setup(x => x.PublishAsync(
|
||||
It.IsAny<Domain.Enums.EventType>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Domain.Enums.EventSeverity>(),
|
||||
It.IsAny<object?>(),
|
||||
It.IsAny<Guid?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock logger for a specific handler type
|
||||
/// </summary>
|
||||
public Mock<ILogger<T>> CreateLogger<T>() where T : GenericHandler
|
||||
{
|
||||
return new Mock<ILogger<T>>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock download service
|
||||
/// </summary>
|
||||
public Mock<IDownloadService> CreateMockDownloadService(string clientName = "Test Client")
|
||||
{
|
||||
var mock = new Mock<IDownloadService>();
|
||||
mock.Setup(x => x.ClientConfig).Returns(new Persistence.Models.Configuration.DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = clientName,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.qBittorrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080")
|
||||
});
|
||||
mock.Setup(x => x.LoginAsync()).Returns(Task.CompletedTask);
|
||||
return mock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the DownloadServiceFactory to return the specified mock services
|
||||
/// </summary>
|
||||
public void SetupDownloadServices(params Mock<IDownloadService>[] services)
|
||||
{
|
||||
foreach (var service in services)
|
||||
{
|
||||
DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(service.Object.ClientConfig))
|
||||
.Returns(service.Object);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fresh DataContext, disposing the old one
|
||||
/// </summary>
|
||||
public DataContext RecreateDataContext(bool seedData = true)
|
||||
{
|
||||
DataContext?.Dispose();
|
||||
DataContext = TestDataContextFactory.Create(seedData);
|
||||
return DataContext;
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
MessageBus.Reset();
|
||||
ArrClientFactory.Reset();
|
||||
ArrQueueIterator.Reset();
|
||||
DownloadServiceFactory.Reset();
|
||||
EventPublisher.Reset();
|
||||
BlocklistProvider.Reset();
|
||||
Cache.Clear();
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
|
||||
SetupDefaultBehaviors();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DataContext?.Dispose();
|
||||
Cache?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory DataContext instances for testing
|
||||
/// </summary>
|
||||
public static class TestDataContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new SQLite in-memory DataContext with default seed data
|
||||
/// </summary>
|
||||
public static DataContext Create(bool seedData = true)
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
if (seedData)
|
||||
{
|
||||
SeedDefaultData(context);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the minimum required data for GenericHandler.ExecuteAsync() to work
|
||||
/// </summary>
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
// General config
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
// Arr configs for all instance types
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
// Queue cleaner config
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
// Content blocker config
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeleteKnownMalware = false,
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
// Download cleaner config
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
Categories = [],
|
||||
UnlinkedEnabled = false,
|
||||
UnlinkedTargetCategory = "",
|
||||
UnlinkedCategories = []
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Sonarr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddSonarrInstance(DataContext context, string url = "http://sonarr:8989", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Sonarr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Radarr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddRadarrInstance(DataContext context, string url = "http://radarr:7878", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Radarr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Lidarr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddLidarrInstance(DataContext context, string url = "http://lidarr:8686", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Lidarr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Readarr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddReadarrInstance(DataContext context, string url = "http://readarr:8787", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Readarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Readarr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled Whisparr instance to the context
|
||||
/// </summary>
|
||||
public static ArrInstance AddWhisparrInstance(DataContext context, string url = "http://whisparr:6969", bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Whisparr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Whisparr",
|
||||
Url = new Uri(url),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an enabled download client to the context
|
||||
/// </summary>
|
||||
public static DownloadClientConfig AddDownloadClient(
|
||||
DataContext context,
|
||||
string name = "Test qBittorrent",
|
||||
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
|
||||
bool enabled = true)
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = enabled,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin"
|
||||
};
|
||||
|
||||
context.DownloadClients.Add(config);
|
||||
context.SaveChanges();
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stall rule to the context
|
||||
/// </summary>
|
||||
public static StallRule AddStallRule(
|
||||
DataContext context,
|
||||
string name = "Test Stall Rule",
|
||||
bool enabled = true,
|
||||
ushort minCompletionPercentage = 0,
|
||||
ushort maxCompletionPercentage = 100,
|
||||
int maxStrikes = 3)
|
||||
{
|
||||
var queueCleanerConfig = context.QueueCleanerConfigs.First();
|
||||
var rule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = enabled,
|
||||
MinCompletionPercentage = minCompletionPercentage,
|
||||
MaxCompletionPercentage = maxCompletionPercentage,
|
||||
MaxStrikes = maxStrikes,
|
||||
QueueCleanerConfigId = queueCleanerConfig.Id
|
||||
};
|
||||
|
||||
context.StallRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a slow rule to the context
|
||||
/// </summary>
|
||||
public static SlowRule AddSlowRule(
|
||||
DataContext context,
|
||||
string name = "Test Slow Rule",
|
||||
bool enabled = true,
|
||||
ushort minCompletionPercentage = 0,
|
||||
ushort maxCompletionPercentage = 100,
|
||||
int maxStrikes = 3,
|
||||
string minSpeed = "1 KB/s")
|
||||
{
|
||||
var queueCleanerConfig = context.QueueCleanerConfigs.First();
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Enabled = enabled,
|
||||
MinCompletionPercentage = minCompletionPercentage,
|
||||
MaxCompletionPercentage = maxCompletionPercentage,
|
||||
MaxStrikes = maxStrikes,
|
||||
MinSpeed = minSpeed,
|
||||
QueueCleanerConfigId = queueCleanerConfig.Id
|
||||
};
|
||||
|
||||
context.SlowRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a clean category to the download cleaner config
|
||||
/// </summary>
|
||||
public static SeedingRule AddSeedingRule(
|
||||
DataContext context,
|
||||
string name = "completed",
|
||||
double maxRatio = 1.0,
|
||||
double minSeedTime = 1.0,
|
||||
double maxSeedTime = -1)
|
||||
{
|
||||
var config = context.DownloadCleanerConfigs.Include(x => x.Categories).First();
|
||||
var category = new SeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
DeleteSourceFiles = true,
|
||||
DownloadCleanerConfigId = config.Id
|
||||
};
|
||||
|
||||
config.Categories.Add(category);
|
||||
context.SeedingRules.Add(category);
|
||||
context.SaveChanges();
|
||||
|
||||
return category;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.MalwareBlocker;
|
||||
|
||||
public class BlocklistProviderTests : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly BlocklistProvider _provider;
|
||||
|
||||
public BlocklistProviderTests()
|
||||
{
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_logger = Substitute.For<ILogger<BlocklistProvider>>();
|
||||
_scopeFactory = Substitute.For<IServiceScopeFactory>();
|
||||
|
||||
_provider = new BlocklistProvider(_logger, _scopeFactory, _cache);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBlocklistType_NotInCache_ReturnsDefaultBlacklist()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetBlocklistType(InstanceType.Sonarr);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(BlocklistType.Blacklist);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public void GetBlocklistType_InCache_ReturnsCachedValue(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
_cache.Set(CacheKeys.BlocklistType(instanceType), BlocklistType.Whitelist);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetBlocklistType(instanceType);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(BlocklistType.Whitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPatterns_NotInCache_ReturnsEmptyBag()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetPatterns(InstanceType.Sonarr);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPatterns_InCache_ReturnsCachedPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new ConcurrentBag<string> { "*.exe", "*.dll", "malware*" };
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Radarr), patterns);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetPatterns(InstanceType.Radarr);
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("*.exe");
|
||||
result.ShouldContain("*.dll");
|
||||
result.ShouldContain("malware*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRegexes_NotInCache_ReturnsEmptyBag()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetRegexes(InstanceType.Lidarr);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRegexes_InCache_ReturnsCachedRegexes()
|
||||
{
|
||||
// Arrange
|
||||
var regexes = new ConcurrentBag<Regex>
|
||||
{
|
||||
new Regex(@"^\d+$"),
|
||||
new Regex(@"test\d+\.exe")
|
||||
};
|
||||
_cache.Set(CacheKeys.BlocklistRegexes(InstanceType.Readarr), regexes);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetRegexes(InstanceType.Readarr);
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMalwarePatterns_NotInCache_ReturnsEmptyBag()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetMalwarePatterns();
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMalwarePatterns_InCache_ReturnsCachedPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new ConcurrentBag<string> { "known_malware.exe", "trojan*", "virus.dll" };
|
||||
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetMalwarePatterns();
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("known_malware.exe");
|
||||
result.ShouldContain("trojan*");
|
||||
result.ShouldContain("virus.dll");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public void GetPatterns_DifferentInstanceTypes_UsesCorrectCacheKey(InstanceType instanceType)
|
||||
{
|
||||
// Arrange - set patterns for each instance type differently
|
||||
var patterns = new ConcurrentBag<string> { $"pattern_for_{instanceType}" };
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetPatterns(instanceType);
|
||||
|
||||
// Assert
|
||||
result.ShouldContain($"pattern_for_{instanceType}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPatterns_DifferentInstanceTypes_ReturnsDifferentPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var sonarrPatterns = new ConcurrentBag<string> { "sonarr_pattern" };
|
||||
var radarrPatterns = new ConcurrentBag<string> { "radarr_pattern" };
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Sonarr), sonarrPatterns);
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(InstanceType.Radarr), radarrPatterns);
|
||||
|
||||
// Act
|
||||
var sonarrResult = _provider.GetPatterns(InstanceType.Sonarr);
|
||||
var radarrResult = _provider.GetPatterns(InstanceType.Radarr);
|
||||
|
||||
// Assert
|
||||
sonarrResult.ShouldContain("sonarr_pattern");
|
||||
sonarrResult.ShouldNotContain("radarr_pattern");
|
||||
radarrResult.ShouldContain("radarr_pattern");
|
||||
radarrResult.ShouldNotContain("sonarr_pattern");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise;
|
||||
|
||||
public class AppriseProxyTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public AppriseProxyTests()
|
||||
{
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private AppriseProxy CreateProxy()
|
||||
{
|
||||
return new AppriseProxy(_httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static ApprisePayload CreatePayload()
|
||||
{
|
||||
return new ApprisePayload
|
||||
{
|
||||
Title = "Test Title",
|
||||
Body = "Test Body"
|
||||
};
|
||||
}
|
||||
|
||||
private static AppriseConfig CreateConfig()
|
||||
{
|
||||
return new AppriseConfig
|
||||
{
|
||||
Url = "http://apprise.local",
|
||||
Key = "test-key"
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidFactory_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_BuildsCorrectUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new AppriseConfig { Url = "http://apprise.local", Key = "my-key" };
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("/notify/my-key", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SetsJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithBasicAuth_SetsAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedAuthHeader = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedAuthHeader = req.Headers.Authorization?.Scheme)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new AppriseConfig { Url = "http://user:pass@apprise.local", Key = "test-key" };
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Basic", capturedAuthHeader);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsAppriseExceptionWithInvalidApiKey()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("API key is invalid", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When424_ThrowsAppriseExceptionWithTagsError()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse((HttpStatusCode)424);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("tags", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public async Task SendNotification_WhenServiceUnavailable_ThrowsAppriseException(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsAppriseException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unable to send notification", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsAppriseException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unable to send notification", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class AppriseProviderTests
|
||||
{
|
||||
private readonly Mock<IAppriseProxy> _proxyMock;
|
||||
private readonly AppriseConfig _config;
|
||||
private readonly AppriseProvider _provider;
|
||||
|
||||
public AppriseProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<IAppriseProxy>();
|
||||
_config = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://apprise.example.com",
|
||||
Key = "testkey",
|
||||
Tags = "tag1,tag2"
|
||||
};
|
||||
|
||||
_provider = new AppriseProvider(
|
||||
"TestApprise",
|
||||
NotificationProviderType.Apprise,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestApprise", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Apprise, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(context.Title, capturedPayload.Title);
|
||||
Assert.Contains(context.Description, capturedPayload.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataInBody()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("TestKey: TestValue", capturedPayload.Body);
|
||||
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Body);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EventSeverity.Information, "info")]
|
||||
[InlineData(EventSeverity.Warning, "warning")]
|
||||
[InlineData(EventSeverity.Important, "failure")]
|
||||
public async Task SendNotificationAsync_MapsEventSeverityToCorrectType(EventSeverity severity, string expectedType)
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = severity,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(expectedType, capturedPayload.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesTagsFromConfig()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("tag1,tag2", capturedPayload.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.ThrowsAsync(new Exception("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_StillIncludesDescription()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
ApprisePayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
|
||||
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("Test Description", capturedPayload.Body);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Notifiarr;
|
||||
|
||||
public class NotifiarrProxyTests
|
||||
{
|
||||
private readonly Mock<ILogger<NotifiarrProxy>> _loggerMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public NotifiarrProxyTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<NotifiarrProxy>>();
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private NotifiarrProxy CreateProxy()
|
||||
{
|
||||
return new NotifiarrProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static NotifiarrPayload CreatePayload()
|
||||
{
|
||||
return new NotifiarrPayload
|
||||
{
|
||||
Notification = new NotifiarrNotification { Update = false },
|
||||
Discord = new Discord
|
||||
{
|
||||
Color = "#FF0000",
|
||||
Text = new Text { Title = "Test", Content = "Test content" },
|
||||
Ids = new Ids { Channel = "123456789" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifiarrConfig CreateConfig()
|
||||
{
|
||||
return new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "test-api-key-12345",
|
||||
ChannelId = "123456789"
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_BuildsCorrectUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var config = new NotifiarrConfig { ApiKey = "my-api-key", ChannelId = "123" };
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("notifiarr.com", capturedUri.ToString());
|
||||
Assert.Contains("passthrough", capturedUri.ToString());
|
||||
Assert.Contains("my-api-key", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SetsJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_LogsTraceWithContent()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsNotifiarrExceptionWithInvalidApiKey()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("API key is invalid", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public async Task SendNotification_WhenServiceUnavailable_ThrowsNotifiarrException(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsNotifiarrException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsNotifiarrException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NotifiarrException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotifiarrProviderTests
|
||||
{
|
||||
private readonly Mock<INotifiarrProxy> _proxyMock;
|
||||
private readonly NotifiarrConfig _config;
|
||||
private readonly NotifiarrProvider _provider;
|
||||
|
||||
public NotifiarrProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<INotifiarrProxy>();
|
||||
_config = new NotifiarrConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiKey = "testapikey1234567890",
|
||||
ChannelId = "123456789012345678"
|
||||
};
|
||||
|
||||
_provider = new NotifiarrProvider(
|
||||
"TestNotifiarr",
|
||||
NotificationProviderType.Notifiarr,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestNotifiarr", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Notifiarr, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.NotNull(capturedPayload.Discord);
|
||||
Assert.Equal(context.Title, capturedPayload.Discord.Text.Title);
|
||||
Assert.Equal(context.Description, capturedPayload.Discord.Text.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_UsesConfiguredChannelId()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("123456789012345678", capturedPayload.Discord.Ids.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataAsFields()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(2, capturedPayload.Discord.Text.Fields.Count);
|
||||
Assert.Contains(capturedPayload.Discord.Text.Fields, f => f.Title == "TestKey" && f.Text == "TestValue");
|
||||
Assert.Contains(capturedPayload.Discord.Text.Fields, f => f.Title == "AnotherKey" && f.Text == "AnotherValue");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EventSeverity.Information, "28a745")] // Green
|
||||
[InlineData(EventSeverity.Warning, "f0ad4e")] // Orange
|
||||
[InlineData(EventSeverity.Important, "bb2124")] // Red
|
||||
public async Task SendNotificationAsync_MapsEventSeverityToCorrectColor(EventSeverity severity, string expectedColor)
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = severity,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(expectedColor, capturedPayload.Discord.Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesCleanuperrLogo()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("Cleanuparr", capturedPayload.Discord.Text.Icon);
|
||||
Assert.NotNull(capturedPayload.Discord.Images.Thumbnail);
|
||||
Assert.Contains("Cleanuparr", capturedPayload.Discord.Images.Thumbnail.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesContextImage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Image = new Uri("https://example.com/image.jpg");
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal(new Uri("https://example.com/image.jpg"), capturedPayload.Discord.Images.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenNoImage_ImagesImageIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Image = null;
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Null(capturedPayload.Discord.Images.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.ThrowsAsync(new Exception("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_HasEmptyFields()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
NotifiarrPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NotifiarrPayload>(), _config))
|
||||
.Callback<NotifiarrPayload, NotifiarrConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Empty(capturedPayload.Discord.Text.Fields);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationConfigurationServiceTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly Mock<ILogger<NotificationConfigurationService>> _loggerMock;
|
||||
private readonly NotificationConfigurationService _service;
|
||||
|
||||
public NotificationConfigurationServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new DataContext(options);
|
||||
_loggerMock = new Mock<ILogger<NotificationConfigurationService>>();
|
||||
_service = new NotificationConfigurationService(_context, _loggerMock.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.EnsureDeleted();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
#region GetActiveProvidersAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_NoProviders_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_WithEnabledProvider_ReturnsProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test Provider", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Test Provider", result[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_WithDisabledProvider_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Disabled Provider", isEnabled: false);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_CachesResults()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test Provider", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act - Call twice
|
||||
var result1 = await _service.GetActiveProvidersAsync();
|
||||
var result2 = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert - Both calls should return same data
|
||||
Assert.Single(result1);
|
||||
Assert.Single(result2);
|
||||
Assert.Equal(result1[0].Id, result2[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_WithMixedProviders_ReturnsOnlyEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var enabledConfig = CreateNotifiarrConfig("Enabled", isEnabled: true);
|
||||
var disabledConfig = CreateNotifiarrConfig("Disabled", isEnabled: false);
|
||||
_context.Set<NotificationConfig>().AddRange(enabledConfig, disabledConfig);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Enabled", result[0].Name);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetProvidersForEventAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetProvidersForEventAsync_NoMatchingProviders_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: false);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProvidersForEventAsync_WithMatchingProvider_ReturnsProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProvidersForEventAsync_TestEvent_AlwaysReturnsEnabledProviders()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true, onStalledStrike: false);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(NotificationEventType.Test);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationEventType.FailedImportStrike, true, false, false, false, false, false)]
|
||||
[InlineData(NotificationEventType.StalledStrike, false, true, false, false, false, false)]
|
||||
[InlineData(NotificationEventType.SlowSpeedStrike, false, false, true, false, false, false)]
|
||||
[InlineData(NotificationEventType.SlowTimeStrike, false, false, true, false, false, false)]
|
||||
[InlineData(NotificationEventType.QueueItemDeleted, false, false, false, true, false, false)]
|
||||
[InlineData(NotificationEventType.DownloadCleaned, false, false, false, false, true, false)]
|
||||
[InlineData(NotificationEventType.CategoryChanged, false, false, false, false, false, true)]
|
||||
public async Task GetProvidersForEventAsync_ReturnsProviderForCorrectEvents(
|
||||
NotificationEventType eventType,
|
||||
bool onFailedImport, bool onStalled, bool onSlow,
|
||||
bool onDeleted, bool onCleaned, bool onCategory)
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = true,
|
||||
OnFailedImportStrike = onFailedImport,
|
||||
OnStalledStrike = onStalled,
|
||||
OnSlowStrike = onSlow,
|
||||
OnQueueItemDeleted = onDeleted,
|
||||
OnDownloadCleaned = onCleaned,
|
||||
OnCategoryChanged = onCategory,
|
||||
NotifiarrConfiguration = new NotifiarrConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiKey = "testapikey1234567890",
|
||||
ChannelId = "123456789012345678"
|
||||
}
|
||||
};
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(eventType);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetProviderByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetProviderByIdAsync_ProviderExists_ReturnsProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProviderByIdAsync(config.Id);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(config.Id, result.Id);
|
||||
Assert.Equal("Test", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProviderByIdAsync_ProviderDoesNotExist_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetProviderByIdAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProviderByIdAsync_DisabledProvider_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Disabled", isEnabled: false);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProviderByIdAsync(config.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvalidateCacheAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateCacheAsync_RefreshesDataOnNextCall()
|
||||
{
|
||||
// Arrange
|
||||
var config1 = CreateNotifiarrConfig("Provider 1", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config1);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// First call to populate cache
|
||||
var result1 = await _service.GetActiveProvidersAsync();
|
||||
Assert.Single(result1);
|
||||
|
||||
// Add another provider
|
||||
var config2 = CreateNotifiarrConfig("Provider 2", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config2);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Without invalidation, should return cached result
|
||||
var result2 = await _service.GetActiveProvidersAsync();
|
||||
Assert.Single(result2);
|
||||
|
||||
// After invalidation, should return updated result
|
||||
await _service.InvalidateCacheAsync();
|
||||
var result3 = await _service.GetActiveProvidersAsync();
|
||||
Assert.Equal(2, result3.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateCacheAsync_LogsDebugMessage()
|
||||
{
|
||||
// Act
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("cache invalidated")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetProvidersForEventAsync_UnknownEventType_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateNotifiarrConfig("Test", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
var unknownEventType = (NotificationEventType)999;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
||||
() => _service.GetProvidersForEventAsync(unknownEventType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveProvidersAsync_DatabaseError_ReturnsEmptyListAndLogsError()
|
||||
{
|
||||
// Arrange - dispose context to simulate database error
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var disposedContext = new DataContext(options);
|
||||
var loggerMock = new Mock<ILogger<NotificationConfigurationService>>();
|
||||
var service = new NotificationConfigurationService(disposedContext, loggerMock.Object);
|
||||
|
||||
await disposedContext.DisposeAsync();
|
||||
|
||||
// Act
|
||||
var result = await service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to load notification providers")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Provider Type Mapping Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateConfigForType(providerType, "Test Provider", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveProvidersAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(providerType, result[0].Type);
|
||||
Assert.Equal("Test Provider", result[0].Name);
|
||||
Assert.NotNull(result[0].Configuration);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateConfigForType(providerType, "Test", isEnabled: true);
|
||||
_context.Set<NotificationConfig>().Add(config);
|
||||
await _context.SaveChangesAsync();
|
||||
await _service.InvalidateCacheAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(providerType, result[0].Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationConfig CreateConfigForType(
|
||||
NotificationProviderType providerType,
|
||||
string name,
|
||||
bool isEnabled)
|
||||
{
|
||||
return providerType switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => CreateNotifiarrConfig(name, isEnabled),
|
||||
NotificationProviderType.Apprise => CreateAppriseConfig(name, isEnabled),
|
||||
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
|
||||
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateNotifiarrConfig(
|
||||
string name,
|
||||
bool isEnabled,
|
||||
bool onStalledStrike = true,
|
||||
bool onFailedImport = true,
|
||||
bool onSlow = true,
|
||||
bool onDeleted = true,
|
||||
bool onCleaned = true,
|
||||
bool onCategory = true)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = onStalledStrike,
|
||||
OnFailedImportStrike = onFailedImport,
|
||||
OnSlowStrike = onSlow,
|
||||
OnQueueItemDeleted = onDeleted,
|
||||
OnDownloadCleaned = onCleaned,
|
||||
OnCategoryChanged = onCategory,
|
||||
NotifiarrConfiguration = new NotifiarrConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiKey = "testapikey1234567890",
|
||||
ChannelId = "123456789012345678"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateAppriseConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
AppriseConfiguration = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://localhost:8000",
|
||||
Key = "testkey"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateNtfyConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
NtfyConfiguration = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "https://ntfy.sh",
|
||||
Topics = ["test-topic"]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreatePushoverConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
PushoverConfiguration = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "test_api_token_1234567890abcd",
|
||||
UserKey = "test_user_key_1234567890abcde"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationConsumerTests
|
||||
{
|
||||
private readonly Mock<ILogger<NotificationService>> _serviceLoggerMock;
|
||||
private readonly Mock<INotificationConfigurationService> _configurationServiceMock;
|
||||
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
|
||||
private readonly NotificationService _notificationService;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public NotificationConsumerTests()
|
||||
{
|
||||
_serviceLoggerMock = new Mock<ILogger<NotificationService>>();
|
||||
_configurationServiceMock = new Mock<INotificationConfigurationService>();
|
||||
_providerFactoryMock = new Mock<INotificationProviderFactory>();
|
||||
_timeProvider = new FakeTimeProvider();
|
||||
|
||||
_notificationService = new NotificationService(
|
||||
_serviceLoggerMock.Object,
|
||||
_configurationServiceMock.Object,
|
||||
_providerFactoryMock.Object);
|
||||
}
|
||||
|
||||
#region Consume Tests - FailedImportStrikeNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_FailedImportStrikeNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test Failed Import",
|
||||
Description = "Test Description",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "TEST123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.FailedImportStrike, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - StalledStrikeNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_StalledStrikeNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<StalledStrikeNotification>();
|
||||
var notification = new StalledStrikeNotification
|
||||
{
|
||||
Title = "Test Stalled",
|
||||
Description = "Stalled Description",
|
||||
Level = NotificationLevel.Important,
|
||||
InstanceType = InstanceType.Sonarr,
|
||||
InstanceUrl = new Uri("http://sonarr.local"),
|
||||
Hash = "STALL123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.StalledStrike, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - SlowSpeedStrikeNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_SlowSpeedStrikeNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<SlowSpeedStrikeNotification>();
|
||||
var notification = new SlowSpeedStrikeNotification
|
||||
{
|
||||
Title = "Slow Speed",
|
||||
Description = "Download too slow",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "SLOW123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.SlowSpeedStrike, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - SlowTimeStrikeNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_SlowTimeStrikeNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<SlowTimeStrikeNotification>();
|
||||
var notification = new SlowTimeStrikeNotification
|
||||
{
|
||||
Title = "Slow Time",
|
||||
Description = "Download taking too long",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "TIME123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.SlowTimeStrike, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - QueueItemDeletedNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_QueueItemDeletedNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<QueueItemDeletedNotification>();
|
||||
var notification = new QueueItemDeletedNotification
|
||||
{
|
||||
Title = "Item Deleted",
|
||||
Description = "Queue item removed",
|
||||
Level = NotificationLevel.Important,
|
||||
InstanceType = InstanceType.Lidarr,
|
||||
InstanceUrl = new Uri("http://lidarr.local"),
|
||||
Hash = "DEL123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.QueueItemDeleted, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - DownloadCleanedNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_DownloadCleanedNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<DownloadCleanedNotification>();
|
||||
var notification = new DownloadCleanedNotification
|
||||
{
|
||||
Title = "Download Cleaned",
|
||||
Description = "Old download removed",
|
||||
Level = NotificationLevel.Information
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.DownloadCleaned, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consume Tests - CategoryChangedNotification
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_CategoryChangedNotification_SendsCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<CategoryChangedNotification>();
|
||||
var notification = new CategoryChangedNotification
|
||||
{
|
||||
Title = "Category Changed",
|
||||
Description = "Category updated",
|
||||
Level = NotificationLevel.Information
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationEventType? capturedEventType = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.Callback<NotificationEventType>(e => capturedEventType = e)
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>())).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(NotificationEventType.CategoryChanged, capturedEventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotificationContext Conversion Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationLevel.Information, EventSeverity.Information)]
|
||||
[InlineData(NotificationLevel.Warning, EventSeverity.Warning)]
|
||||
[InlineData(NotificationLevel.Important, EventSeverity.Important)]
|
||||
public async Task Consume_MapsNotificationLevelToSeverity(NotificationLevel level, EventSeverity expectedSeverity)
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test",
|
||||
Description = "Test",
|
||||
Level = level,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "LEVEL123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal(expectedSeverity, capturedContext.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_ArrNotification_IncludesArrDataInContext()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test",
|
||||
Description = "Test",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Sonarr,
|
||||
InstanceUrl = new Uri("http://sonarr.local"),
|
||||
Hash = "ABC123",
|
||||
Image = new Uri("http://example.com/image.jpg")
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("Sonarr", capturedContext.Data["Instance type"]);
|
||||
Assert.Equal("http://sonarr.local/", capturedContext.Data["Url"]);
|
||||
Assert.Equal("ABC123", capturedContext.Data["Hash"]);
|
||||
Assert.Equal(new Uri("http://example.com/image.jpg"), capturedContext.Image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithCustomFields_IncludesFieldsInContext()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test",
|
||||
Description = "Test",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "XYZ789",
|
||||
Fields = new List<NotificationField>
|
||||
{
|
||||
new() { Key = "CustomKey1", Value = "CustomValue1" },
|
||||
new() { Key = "CustomKey2", Value = "CustomValue2" }
|
||||
}
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("CustomValue1", capturedContext.Data["CustomKey1"]);
|
||||
Assert.Equal("CustomValue2", capturedContext.Data["CustomKey2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_NonArrNotification_DoesNotIncludeArrData()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<DownloadCleanedNotification>();
|
||||
var notification = new DownloadCleanedNotification
|
||||
{
|
||||
Title = "Download Cleaned",
|
||||
Description = "Test",
|
||||
Level = NotificationLevel.Information
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
|
||||
_providerFactoryMock
|
||||
.Setup(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.False(capturedContext.Data.ContainsKey("Instance type"));
|
||||
Assert.False(capturedContext.Data.ContainsKey("Url"));
|
||||
Assert.False(capturedContext.Data.ContainsKey("Hash"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region No Providers Configured Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WhenNoProvidersConfigured_DoesNotSendNotification()
|
||||
{
|
||||
// Arrange
|
||||
var consumer = CreateConsumer<FailedImportStrikeNotification>();
|
||||
var notification = new FailedImportStrikeNotification
|
||||
{
|
||||
Title = "Test",
|
||||
Description = "Test",
|
||||
Level = NotificationLevel.Warning,
|
||||
InstanceType = InstanceType.Radarr,
|
||||
InstanceUrl = new Uri("http://radarr.local"),
|
||||
Hash = "NOPROV123"
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(notification);
|
||||
|
||||
_configurationServiceMock
|
||||
.Setup(s => s.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto>());
|
||||
|
||||
// Act
|
||||
await ConsumeWithTimeAdvance(consumer, contextMock);
|
||||
|
||||
// Assert
|
||||
_providerFactoryMock.Verify(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()), Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private NotificationConsumer<T> CreateConsumer<T>() where T : Notification
|
||||
{
|
||||
var loggerMock = new Mock<ILogger<NotificationConsumer<T>>>();
|
||||
return new NotificationConsumer<T>(loggerMock.Object, _notificationService, _timeProvider);
|
||||
}
|
||||
|
||||
private static Mock<ConsumeContext<T>> CreateConsumeContextMock<T>(T message) where T : class
|
||||
{
|
||||
var mock = new Mock<ConsumeContext<T>>();
|
||||
mock.Setup(c => c.Message).Returns(message);
|
||||
return mock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the consumer and advances time past the 1-second spam prevention delay
|
||||
/// </summary>
|
||||
private async Task ConsumeWithTimeAdvance<T>(NotificationConsumer<T> consumer, Mock<ConsumeContext<T>> contextMock) where T : Notification
|
||||
{
|
||||
var task = consumer.Consume(contextMock.Object);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await task;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationProviderFactoryTests
|
||||
{
|
||||
private readonly Mock<IAppriseProxy> _appriseProxyMock;
|
||||
private readonly Mock<INtfyProxy> _ntfyProxyMock;
|
||||
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
|
||||
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NotificationProviderFactory _factory;
|
||||
|
||||
public NotificationProviderFactoryTests()
|
||||
{
|
||||
_appriseProxyMock = new Mock<IAppriseProxy>();
|
||||
_ntfyProxyMock = new Mock<INtfyProxy>();
|
||||
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
|
||||
_pushoverProxyMock = new Mock<IPushoverProxy>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_appriseProxyMock.Object);
|
||||
services.AddSingleton(_ntfyProxyMock.Object);
|
||||
services.AddSingleton(_notifiarrProxyMock.Object);
|
||||
services.AddSingleton(_pushoverProxyMock.Object);
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_factory = new NotificationProviderFactory(_serviceProvider);
|
||||
}
|
||||
|
||||
#region CreateProvider Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_AppriseType_CreatesAppriseProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestApprise",
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true,
|
||||
Configuration = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://apprise.example.com",
|
||||
Key = "testkey"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<AppriseProvider>(provider);
|
||||
Assert.Equal("TestApprise", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Apprise, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_NtfyType_CreatesNtfyProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestNtfy",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true,
|
||||
Configuration = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "test-topic" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<NtfyProvider>(provider);
|
||||
Assert.Equal("TestNtfy", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Ntfy, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_NotifiarrType_CreatesNotifiarrProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestNotifiarr",
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = true,
|
||||
Configuration = new NotifiarrConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiKey = "testapikey1234567890",
|
||||
ChannelId = "123456789012345678"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<NotifiarrProvider>(provider);
|
||||
Assert.Equal("TestNotifiarr", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Notifiarr, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_PushoverType_CreatesPushoverProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestPushover",
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = true,
|
||||
Configuration = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "test-api-token",
|
||||
UserKey = "test-user-key",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<PushoverProvider>(provider);
|
||||
Assert.Equal("TestPushover", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Pushover, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestUnsupported",
|
||||
Type = (NotificationProviderType)999, // Invalid type
|
||||
IsEnabled = true,
|
||||
Configuration = new object()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotSupportedException>(() => _factory.CreateProvider(config));
|
||||
Assert.Contains("not supported", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_AppriseType_UsesCorrectProxy()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestApprise",
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true,
|
||||
Configuration = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://apprise.example.com",
|
||||
Key = "testkey"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert - provider was created with the injected proxy
|
||||
Assert.NotNull(provider);
|
||||
// The proxy would be used when SendNotificationAsync is called
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_PreservesProviderName()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "My Custom Provider Name",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true,
|
||||
Configuration = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "test" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("My Custom Provider Name", provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_PreservesProviderType()
|
||||
{
|
||||
// Arrange
|
||||
var configs = new[]
|
||||
{
|
||||
(Type: NotificationProviderType.Apprise, Config: (object)new AppriseConfig { Id = Guid.NewGuid(), Url = "http://test.com", Key = "key" }),
|
||||
(Type: NotificationProviderType.Ntfy, Config: (object)new NtfyConfig { Id = Guid.NewGuid(), ServerUrl = "http://test.com", Topics = new List<string> { "t" }, AuthenticationType = NtfyAuthenticationType.None, Priority = NtfyPriority.Default }),
|
||||
(Type: NotificationProviderType.Notifiarr, Config: (object)new NotifiarrConfig { Id = Guid.NewGuid(), ApiKey = "1234567890", ChannelId = "12345" }),
|
||||
(Type: NotificationProviderType.Pushover, Config: (object)new PushoverConfig { Id = Guid.NewGuid(), ApiToken = "token", UserKey = "user", Devices = new List<string>(), Priority = PushoverPriority.Normal, Sound = "", Tags = new List<string>() })
|
||||
};
|
||||
|
||||
foreach (var (type, configObj) in configs)
|
||||
{
|
||||
var dto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Test-{type}",
|
||||
Type = type,
|
||||
IsEnabled = true,
|
||||
Configuration = configObj
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = _factory.CreateProvider(dto);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(type, provider.Type);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Service Resolution Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_WhenProxyNotRegistered_ThrowsException()
|
||||
{
|
||||
// Arrange - create a service provider without the proxy
|
||||
var emptyServices = new ServiceCollection();
|
||||
var emptyServiceProvider = emptyServices.BuildServiceProvider();
|
||||
var factoryWithNoServices = new NotificationProviderFactory(emptyServiceProvider);
|
||||
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestApprise",
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true,
|
||||
Configuration = new AppriseConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Url = "http://test.com",
|
||||
Key = "key"
|
||||
}
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => factoryWithNoServices.CreateProvider(config));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationPublisherTests
|
||||
{
|
||||
private readonly Mock<ILogger<NotificationPublisher>> _loggerMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly Mock<INotificationConfigurationService> _configServiceMock;
|
||||
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
|
||||
private readonly NotificationPublisher _publisher;
|
||||
|
||||
public NotificationPublisherTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<NotificationPublisher>>();
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
_configServiceMock = new Mock<INotificationConfigurationService>();
|
||||
_providerFactoryMock = new Mock<INotificationProviderFactory>();
|
||||
|
||||
// Setup dry run interceptor to call through
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns<Delegate, object[]>(async (action, parameters) =>
|
||||
{
|
||||
if (action is Func<(NotificationEventType, NotificationContext), Task> func && parameters.Length > 0)
|
||||
{
|
||||
var param = ((NotificationEventType, NotificationContext))parameters[0];
|
||||
await func(param);
|
||||
}
|
||||
});
|
||||
|
||||
_publisher = new NotificationPublisher(
|
||||
_loggerMock.Object,
|
||||
_dryRunInterceptorMock.Object,
|
||||
_configServiceMock.Object,
|
||||
_providerFactoryMock.Object);
|
||||
}
|
||||
|
||||
private void SetupContext(InstanceType instanceType = InstanceType.Sonarr)
|
||||
{
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Show",
|
||||
DownloadId = "ABCD1234",
|
||||
Status = "Downloading",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
ContextProvider.Set(nameof(QueueRecord), record);
|
||||
ContextProvider.Set(nameof(InstanceType), (object)instanceType);
|
||||
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), new Uri("http://sonarr.local"));
|
||||
}
|
||||
|
||||
private void SetupDownloadCleanerContext()
|
||||
{
|
||||
ContextProvider.Set("downloadName", "Test Download");
|
||||
ContextProvider.Set("hash", "HASH123");
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsAllDependencies()
|
||||
{
|
||||
// Assert
|
||||
Assert.NotNull(_publisher);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyStrike Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WithStalledStrike_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
var rule = new StallRule { Name = "Test Rule" };
|
||||
ContextProvider.Set<QueueRule>(rule);
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.StalledStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.Stalled, 1);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.StalledStrike &&
|
||||
c.Data.ContainsKey("Strike type") &&
|
||||
c.Data["Strike type"] == "Stalled")), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WithFailedImportStrike_MapsToCorrectEventType()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 2);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.FailedImportStrike &&
|
||||
c.Data["Strike count"] == "2")), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StrikeType.Stalled, NotificationEventType.StalledStrike)]
|
||||
[InlineData(StrikeType.DownloadingMetadata, NotificationEventType.StalledStrike)]
|
||||
[InlineData(StrikeType.FailedImport, NotificationEventType.FailedImportStrike)]
|
||||
[InlineData(StrikeType.SlowSpeed, NotificationEventType.SlowSpeedStrike)]
|
||||
[InlineData(StrikeType.SlowTime, NotificationEventType.SlowTimeStrike)]
|
||||
public async Task NotifyStrike_MapsStrikeTypeToCorrectEventType(StrikeType strikeType, NotificationEventType expectedEventType)
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
if (strikeType is StrikeType.Stalled or StrikeType.SlowSpeed or StrikeType.SlowTime)
|
||||
{
|
||||
var rule = new StallRule { Name = "Test Rule" };
|
||||
ContextProvider.Set<QueueRule>(rule);
|
||||
}
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(expectedEventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(strikeType, 1);
|
||||
|
||||
// Assert
|
||||
_configServiceMock.Verify(c => c.GetProvidersForEventAsync(expectedEventType), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WhenNoProviders_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto>());
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WhenProviderThrows_LogsWarningAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Provider failed"));
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act - Should not throw
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyQueueItemDeleted Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyQueueItemDeleted_SendsNotificationWithCorrectContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.QueueItemDeleted &&
|
||||
c.Data["Reason"] == "Stalled" &&
|
||||
c.Data["Removed from client?"] == "True" &&
|
||||
c.Severity == EventSeverity.Important)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyQueueItemDeleted_WhenRemoveFromClientFalse_ReflectsInContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.MalwareFileFound);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.Data["Removed from client?"] == "False" &&
|
||||
c.Data["Reason"] == "MalwareFileFound")), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyDownloadCleaned Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDownloadCleaned_SendsNotificationWithCorrectContext()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyDownloadCleaned(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.DownloadCleaned &&
|
||||
c.Description == "Test Download" &&
|
||||
c.Data["Category"] == "movies" &&
|
||||
c.Data["Ratio"] == "2.5" &&
|
||||
c.Data["Seeding hours"] == "48")), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDownloadCleaned_WithSeedingTime_RoundsToWholeHours()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(24.7), "tv", CleanReason.MaxSeedTimeReached);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("25", capturedContext.Data["Seeding hours"]); // Rounds to 25
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifyCategoryChanged Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyCategoryChanged_WhenNotTag_IncludesOldAndNewCategory()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyCategoryChanged("tv-sonarr", "seeding", false);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.EventType == NotificationEventType.CategoryChanged &&
|
||||
c.Title == "Category changed" &&
|
||||
c.Data["Old category"] == "tv-sonarr" &&
|
||||
c.Data["New category"] == "seeding")), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyCategoryChanged_WhenIsTag_IncludesOnlyTag()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
NotificationContext? capturedContext = null;
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.Callback<NotificationContext>(c => capturedContext = c)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyCategoryChanged("", "seeded", true);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("Tag added", capturedContext.Title);
|
||||
Assert.True(capturedContext.Data.ContainsKey("Tag"));
|
||||
Assert.Equal("seeded", capturedContext.Data["Tag"]);
|
||||
Assert.False(capturedContext.Data.ContainsKey("Old category"));
|
||||
Assert.False(capturedContext.Data.ContainsKey("New category"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyCategoryChanged_SetsSeverityToInformation()
|
||||
{
|
||||
// Arrange
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
var providerDto = CreateProviderDto();
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.CategoryChanged))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyCategoryChanged("old", "new", false);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.Severity == EventSeverity.Information)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests (through notify methods)
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenMultipleProviders_SendsToAll()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto1 = CreateProviderDto("Provider1");
|
||||
var providerDto2 = CreateProviderDto("Provider2");
|
||||
var providerMock1 = new Mock<INotificationProvider>();
|
||||
var providerMock2 = new Mock<INotificationProvider>();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto1, providerDto2 });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto1))
|
||||
.Returns(providerMock1.Object);
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto2))
|
||||
.Returns(providerMock2.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
providerMock1.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
|
||||
providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenOneProviderFails_OthersStillSend()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
|
||||
var providerDto1 = CreateProviderDto("Provider1");
|
||||
var providerDto2 = CreateProviderDto("Provider2");
|
||||
var providerMock1 = new Mock<INotificationProvider>();
|
||||
var providerMock2 = new Mock<INotificationProvider>();
|
||||
|
||||
providerMock1.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Failed"));
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerDto1, providerDto2 });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto1))
|
||||
.Returns(providerMock1.Object);
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerDto2))
|
||||
.Returns(providerMock2.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert - Provider2 should still be called
|
||||
providerMock2.Verify(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_UsesDryRunInterceptor()
|
||||
{
|
||||
// Arrange
|
||||
SetupContext();
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(It.IsAny<NotificationEventType>()))
|
||||
.ReturnsAsync(new List<NotificationProviderDto>());
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
|
||||
It.IsAny<Func<(NotificationEventType, NotificationContext), Task>>(),
|
||||
It.IsAny<(NotificationEventType, NotificationContext)>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyStrike_WhenExceptionOccurs_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
// Setup dry run interceptor to throw when called
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.ThrowsAsync(new Exception("Interceptor failed"));
|
||||
|
||||
SetupContext();
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to notify strike")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyQueueItemDeleted_WhenExceptionOccurs_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.ThrowsAsync(new Exception("Error"));
|
||||
|
||||
SetupContext();
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify queue item deleted")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDownloadCleaned_WhenExceptionOccurs_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.ThrowsAsync(new Exception("Error"));
|
||||
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(1), "test", CleanReason.MaxRatioReached);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify download cleaned")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyCategoryChanged_WhenExceptionOccurs_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.ThrowsAsync(new Exception("Error"));
|
||||
|
||||
SetupDownloadCleanerContext();
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyCategoryChanged("old", "new", false);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to notify category changed")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationProviderDto CreateProviderDto(string name = "TestProvider")
|
||||
{
|
||||
return new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true
|
||||
},
|
||||
Configuration = new { ApiKey = "test", ChannelId = "123" }
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<NotificationService>> _loggerMock;
|
||||
private readonly Mock<INotificationConfigurationService> _configServiceMock;
|
||||
private readonly Mock<INotificationProviderFactory> _providerFactoryMock;
|
||||
private readonly NotificationService _service;
|
||||
|
||||
public NotificationServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<NotificationService>>();
|
||||
_configServiceMock = new Mock<INotificationConfigurationService>();
|
||||
_providerFactoryMock = new Mock<INotificationProviderFactory>();
|
||||
|
||||
_service = new NotificationService(
|
||||
_loggerMock.Object,
|
||||
_configServiceMock.Object,
|
||||
_providerFactoryMock.Object);
|
||||
}
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_NoProviders_DoesNotSendNotifications()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.QueueItemDeleted;
|
||||
var context = CreateTestContext();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto>());
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
_providerFactoryMock.Verify(f => f.CreateProvider(It.IsAny<NotificationProviderDto>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithProvider_SendsNotification()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.DownloadCleaned;
|
||||
var context = CreateTestContext();
|
||||
var providerConfig = CreateProviderConfig("TestProvider");
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerConfig });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithMultipleProviders_SendsToAll()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.StalledStrike;
|
||||
var context = CreateTestContext();
|
||||
var provider1Config = CreateProviderConfig("Provider1");
|
||||
var provider2Config = CreateProviderConfig("Provider2");
|
||||
|
||||
var provider1Mock = new Mock<INotificationProvider>();
|
||||
provider1Mock.SetupGet(p => p.Name).Returns("Provider1");
|
||||
|
||||
var provider2Mock = new Mock<INotificationProvider>();
|
||||
provider2Mock.SetupGet(p => p.Name).Returns("Provider2");
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { provider1Config, provider2Config });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(provider1Config))
|
||||
.Returns(provider1Mock.Object);
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(provider2Config))
|
||||
.Returns(provider2Mock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
provider1Mock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
provider2Mock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_OneProviderFails_OthersStillExecute()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.CategoryChanged;
|
||||
var context = CreateTestContext();
|
||||
var failingProviderConfig = CreateProviderConfig("FailingProvider");
|
||||
var successProviderConfig = CreateProviderConfig("SuccessProvider");
|
||||
|
||||
var failingProviderMock = new Mock<INotificationProvider>();
|
||||
failingProviderMock.SetupGet(p => p.Name).Returns("FailingProvider");
|
||||
failingProviderMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Provider failed"));
|
||||
|
||||
var successProviderMock = new Mock<INotificationProvider>();
|
||||
successProviderMock.SetupGet(p => p.Name).Returns("SuccessProvider");
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { failingProviderConfig, successProviderConfig });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(failingProviderConfig))
|
||||
.Returns(failingProviderMock.Object);
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(successProviderConfig))
|
||||
.Returns(successProviderMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert - both providers should have been called
|
||||
failingProviderMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
successProviderMock.Verify(p => p.SendNotificationAsync(context), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_ProviderFails_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.QueueItemDeleted;
|
||||
var context = CreateTestContext();
|
||||
var providerConfig = CreateProviderConfig("FailingProvider");
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Provider failed"));
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ReturnsAsync(new List<NotificationProviderDto> { providerConfig });
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_ConfigServiceThrows_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = NotificationEventType.SlowSpeedStrike;
|
||||
var context = CreateTestContext();
|
||||
|
||||
_configServiceMock.Setup(c => c.GetProvidersForEventAsync(eventType))
|
||||
.ThrowsAsync(new Exception("Config service failed"));
|
||||
|
||||
// Act
|
||||
await _service.SendNotificationAsync(eventType, context);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send notifications")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendTestNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_SendsTestContext()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = CreateProviderConfig("TestProvider");
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendTestNotificationAsync(providerConfig);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(c =>
|
||||
c.EventType == NotificationEventType.Test &&
|
||||
c.Title == "Test Notification from Cleanuparr" &&
|
||||
c.Description.Contains("test notification") &&
|
||||
c.Severity == EventSeverity.Information &&
|
||||
c.Data != null &&
|
||||
c.Data.ContainsKey("Test time") &&
|
||||
c.Data.ContainsKey("Provider type")
|
||||
)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_Success_LogsInformation()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = CreateProviderConfig("TestProvider");
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("TestProvider");
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendTestNotificationAsync(providerConfig);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Test notification sent successfully")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_ProviderFails_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = CreateProviderConfig("FailingProvider");
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Test notification failed"));
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _service.SendTestNotificationAsync(providerConfig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_ProviderFails_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = CreateProviderConfig("FailingProvider");
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("FailingProvider");
|
||||
providerMock.Setup(p => p.SendNotificationAsync(It.IsAny<NotificationContext>()))
|
||||
.ThrowsAsync(new Exception("Test notification failed"));
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await _service.SendTestNotificationAsync(providerConfig);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to send test notification")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_IncludesProviderTypeInData()
|
||||
{
|
||||
// Arrange
|
||||
var providerConfig = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestNtfyProvider",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var providerMock = new Mock<INotificationProvider>();
|
||||
providerMock.SetupGet(p => p.Name).Returns("TestNtfyProvider");
|
||||
|
||||
_providerFactoryMock.Setup(f => f.CreateProvider(providerConfig))
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _service.SendTestNotificationAsync(providerConfig);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(c =>
|
||||
c.Data["Provider type"] == "Ntfy"
|
||||
)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["Key1"] = "Value1",
|
||||
["Key2"] = "Value2"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationProviderDto CreateProviderConfig(string name)
|
||||
{
|
||||
return new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Ntfy;
|
||||
|
||||
public class NtfyProxyTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public NtfyProxyTests()
|
||||
{
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private NtfyProxy CreateProxy()
|
||||
{
|
||||
return new NtfyProxy(_httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static NtfyPayload CreatePayload()
|
||||
{
|
||||
return new NtfyPayload
|
||||
{
|
||||
Topic = "test-topic",
|
||||
Message = "Test message",
|
||||
Title = "Test Title"
|
||||
};
|
||||
}
|
||||
|
||||
private static NtfyConfig CreateConfig(NtfyAuthenticationType authType = NtfyAuthenticationType.None)
|
||||
{
|
||||
return new NtfyConfig
|
||||
{
|
||||
ServerUrl = "http://ntfy.local",
|
||||
Topics = new List<string> { "test-topic" },
|
||||
AuthenticationType = authType,
|
||||
Username = authType == NtfyAuthenticationType.BasicAuth ? "user" : null,
|
||||
Password = authType == NtfyAuthenticationType.BasicAuth ? "pass" : null,
|
||||
AccessToken = authType == NtfyAuthenticationType.AccessToken ? "token123" : null
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidFactory_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SetsJsonContentType()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/json", capturedContentType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Authentication Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithNoAuth_DoesNotSetAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
bool hasAuthHeader = false;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
hasAuthHeader = req.Headers.Authorization != null)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.None));
|
||||
|
||||
// Assert
|
||||
Assert.False(hasAuthHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithBasicAuth_SetsBasicAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedAuthScheme = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedAuthScheme = req.Headers.Authorization?.Scheme)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.BasicAuth));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Basic", capturedAuthScheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithAccessToken_SetsBearerAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedAuthScheme = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedAuthScheme = req.Headers.Authorization?.Scheme)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), CreateConfig(NtfyAuthenticationType.AccessToken));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Bearer", capturedAuthScheme);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When400_ThrowsNtfyExceptionWithBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.BadRequest);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Bad request", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsNtfyExceptionWithUnauthorized()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unauthorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When413_ThrowsNtfyExceptionWithPayloadTooLarge()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.RequestEntityTooLarge);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Payload too large", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When429_ThrowsNtfyExceptionWithRateLimited()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.TooManyRequests);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Rate limited", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When507_ThrowsNtfyExceptionWithInsufficientStorage()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InsufficientStorage);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Insufficient storage", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsNtfyException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unable to send notification", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsNtfyException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<NtfyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("Unable to send notification", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class NtfyProviderTests
|
||||
{
|
||||
private readonly Mock<INtfyProxy> _proxyMock;
|
||||
private readonly NtfyConfig _config;
|
||||
private readonly NtfyProvider _provider;
|
||||
|
||||
public NtfyProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<INtfyProxy>();
|
||||
_config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "test-topic" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default,
|
||||
Tags = new List<string> { "tag1", "tag2" }
|
||||
};
|
||||
|
||||
_provider = new NtfyProvider(
|
||||
"TestNtfy",
|
||||
NotificationProviderType.Ntfy,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestNtfy", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Ntfy, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NtfyPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("test-topic", capturedPayload.Topic);
|
||||
Assert.Equal(context.Title, capturedPayload.Title);
|
||||
Assert.Contains(context.Description, capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithMultipleTopics_SendsToAllTopics()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "topic1", "topic2", "topic3" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
var capturedPayloads = new List<NtfyPayload>();
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayloads.Add(payload))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, capturedPayloads.Count);
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "topic1");
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "topic2");
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "topic3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
NtfyPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("TestKey: TestValue", capturedPayload.Message);
|
||||
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_UsesPriorityFromConfig()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "test" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.High,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
NtfyPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal((int)NtfyPriority.High, capturedPayload.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesTagsFromConfig()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
NtfyPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.NotNull(capturedPayload.Tags);
|
||||
Assert.Contains("tag1", capturedPayload.Tags);
|
||||
Assert.Contains("tag2", capturedPayload.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TrimsTopicNames()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { " topic-with-spaces " },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
NtfyPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("topic-with-spaces", capturedPayload.Topic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_SkipsEmptyTopics()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://ntfy.example.com",
|
||||
Topics = new List<string> { "valid-topic", "", " ", "another-valid" },
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
Priority = NtfyPriority.Default,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new NtfyProvider("TestNtfy", NotificationProviderType.Ntfy, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
var capturedPayloads = new List<NtfyPayload>();
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, cfg) => capturedPayloads.Add(payload))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, capturedPayloads.Count);
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "valid-topic");
|
||||
Assert.Contains(capturedPayloads, p => p.Topic == "another-valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.ThrowsAsync(new Exception("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_MessageContainsOnlyDescription()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description Only",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
NtfyPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<NtfyPayload>(), _config))
|
||||
.Callback<NtfyPayload, NtfyConfig>((payload, config) => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("Test Description Only", capturedPayload.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Pushover;
|
||||
|
||||
public class PushoverProxyTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public PushoverProxyTests()
|
||||
{
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private PushoverProxy CreateProxy()
|
||||
{
|
||||
return new PushoverProxy(_httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static PushoverPayload CreatePayload(int priority = 0)
|
||||
{
|
||||
return new PushoverPayload
|
||||
{
|
||||
Token = "test-token",
|
||||
User = "test-user",
|
||||
Message = "Test message",
|
||||
Title = "Test Title",
|
||||
Priority = priority,
|
||||
Retry = priority == 2 ? 60 : null,
|
||||
Expire = priority == 2 ? 3600 : null
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidFactory_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var proxy = CreateProxy();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(proxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesHttpClientWithCorrectName()
|
||||
{
|
||||
// Act
|
||||
_ = CreateProxy();
|
||||
|
||||
// Assert
|
||||
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Success Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupSuccessResponse();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await proxy.SendNotification(CreatePayload());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsPostRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
HttpMethod? capturedMethod = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpMethod.Post, capturedMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_SendsToCorrectUrl()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
Uri? capturedUri = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Equal("https://api.pushover.net/1/messages.json", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_UsesFormUrlEncodedContent()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContentType = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
|
||||
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/x-www-form-urlencoded", capturedContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_IncludesRequiredFieldsInPayload()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = CreatePayload();
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("token=test-token", capturedContent);
|
||||
Assert.Contains("user=test-user", capturedContent);
|
||||
Assert.Contains("message=Test+message", capturedContent);
|
||||
Assert.Contains("priority=0", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithEmergencyPriority_IncludesRetryAndExpire()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = CreatePayload(priority: 2); // Emergency
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("retry=60", capturedContent);
|
||||
Assert.Contains("expire=3600", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithNonEmergencyPriority_DoesNotIncludeRetryAndExpire()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = CreatePayload(priority: 1); // High, not Emergency
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.DoesNotContain("retry=", capturedContent);
|
||||
Assert.DoesNotContain("expire=", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithSound_IncludesSound()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = new PushoverPayload
|
||||
{
|
||||
Token = "test-token",
|
||||
User = "test-user",
|
||||
Message = "Test message",
|
||||
Title = "Test Title",
|
||||
Priority = 0,
|
||||
Sound = "cosmic"
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("sound=cosmic", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithDevice_IncludesDevice()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = new PushoverPayload
|
||||
{
|
||||
Token = "test-token",
|
||||
User = "test-user",
|
||||
Message = "Test message",
|
||||
Title = "Test Title",
|
||||
Priority = 0,
|
||||
Device = "my-phone"
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("device=my-phone", capturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WithTags_IncludesTags()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
string? capturedContent = null;
|
||||
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
|
||||
capturedContent = await req.Content!.ReadAsStringAsync())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
|
||||
var payload = new PushoverPayload
|
||||
{
|
||||
Token = "test-token",
|
||||
User = "test-user",
|
||||
Message = "Test message",
|
||||
Title = "Test Title",
|
||||
Priority = 0,
|
||||
Tags = "tag1,tag2"
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContent);
|
||||
Assert.Contains("tags=tag1%2Ctag2", capturedContent); // URL-encoded comma
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotification Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When400_ThrowsPushoverExceptionWithBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.BadRequest, "{\"status\":0,\"errors\":[\"invalid token\"]}");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("Bad request", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When401_ThrowsPushoverExceptionWithUnauthorized()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.Unauthorized, "{\"status\":0,\"errors\":[\"invalid api key\"]}");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("Invalid API token or user key", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When429_ThrowsPushoverExceptionWithRateLimited()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse((HttpStatusCode)429, "{\"status\":0,\"errors\":[\"rate limit exceeded\"]}");
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("Rate limit exceeded", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenApiReturnsStatus0_ThrowsPushoverException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"status\":0,\"errors\":[\"user key is invalid\"]}")
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("user key is invalid", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsPushoverException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Network error"));
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<PushoverException>(() =>
|
||||
proxy.SendNotification(CreatePayload()));
|
||||
Assert.Contains("Unable to connect to Pushover API", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupSuccessResponse()
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(CreateSuccessResponse());
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateSuccessResponse()
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"status\":1,\"request\":\"abc123\"}")
|
||||
};
|
||||
}
|
||||
|
||||
private void SetupErrorResponse(HttpStatusCode statusCode, string responseBody)
|
||||
{
|
||||
_httpMessageHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(responseBody)
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
|
||||
|
||||
public class PushoverProviderTests
|
||||
{
|
||||
private readonly Mock<IPushoverProxy> _proxyMock;
|
||||
private readonly PushoverConfig _config;
|
||||
private readonly PushoverProvider _provider;
|
||||
|
||||
public PushoverProviderTests()
|
||||
{
|
||||
_proxyMock = new Mock<IPushoverProxy>();
|
||||
_config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "test-api-token",
|
||||
UserKey = "test-user-key",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Retry = null,
|
||||
Expire = null,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
_provider = new PushoverProvider(
|
||||
"TestPushover",
|
||||
NotificationProviderType.Pushover,
|
||||
_config,
|
||||
_proxyMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsNameCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("TestPushover", _provider.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTypeCorrectly()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(NotificationProviderType.Pushover, _provider.Type);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_CallsProxyWithCorrectPayload()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
PushoverPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("test-api-token", capturedPayload.Token);
|
||||
Assert.Equal("test-user-key", capturedPayload.User);
|
||||
Assert.Equal(context.Title, capturedPayload.Title);
|
||||
Assert.Contains(context.Description, capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_IncludesDataInMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
context.Data["TestKey"] = "TestValue";
|
||||
context.Data["AnotherKey"] = "AnotherValue";
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Contains("TestKey: TestValue", capturedPayload.Message);
|
||||
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_UsesPriorityFromConfig()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.High,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal((int)PushoverPriority.High, capturedPayload.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmergencyPriority_IncludesRetryAndExpire()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Emergency,
|
||||
Sound = "",
|
||||
Retry = 60,
|
||||
Expire = 3600,
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal((int)PushoverPriority.Emergency, capturedPayload.Priority);
|
||||
Assert.Equal(60, capturedPayload.Retry);
|
||||
Assert.Equal(3600, capturedPayload.Expire);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithNonEmergencyPriority_DoesNotIncludeRetryAndExpire()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.High, // Not Emergency
|
||||
Sound = "",
|
||||
Retry = 60, // Should be ignored
|
||||
Expire = 3600, // Should be ignored
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Null(capturedPayload.Retry);
|
||||
Assert.Null(capturedPayload.Expire);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithDevices_JoinsDevicesAsString()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string> { "device1", "device2", "device3" },
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("device1,device2,device3", capturedPayload.Device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyDevices_DeviceIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Null(capturedPayload.Device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithTags_JoinsTagsAsString()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string> { "tag1", "tag2" }
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("tag1,tag2", capturedPayload.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithSound_IncludesSound()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string>(),
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "cosmic",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("cosmic", capturedPayload.Sound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TruncatesLongMessage()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = new string('A', 2000), // Very long message
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.True(capturedPayload.Message.Length <= 1024);
|
||||
Assert.EndsWith("...", capturedPayload.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TruncatesLongTitle()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = new string('B', 300), // Very long title
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.True(capturedPayload.Title!.Length <= 250);
|
||||
Assert.EndsWith("...", capturedPayload.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_TrimsDeviceNames()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string> { " device1 ", "device2 " },
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("device1,device2", capturedPayload.Device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_SkipsEmptyDevices()
|
||||
{
|
||||
// Arrange
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApiToken = "token",
|
||||
UserKey = "user",
|
||||
Devices = new List<string> { "device1", "", " ", "device2" },
|
||||
Priority = PushoverPriority.Normal,
|
||||
Sound = "",
|
||||
Tags = new List<string>()
|
||||
};
|
||||
|
||||
var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object);
|
||||
var context = CreateTestContext();
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("device1,device2", capturedPayload.Device);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateTestContext();
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.ThrowsAsync(new PushoverException("Proxy error"));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<PushoverException>(() => _provider.SendNotificationAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_WithEmptyData_MessageContainsOnlyDescription()
|
||||
{
|
||||
// Arrange
|
||||
var context = new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.Test,
|
||||
Title = "Test Title",
|
||||
Description = "Test Description Only",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
PushoverPayload? capturedPayload = null;
|
||||
|
||||
_proxyMock.Setup(p => p.SendNotification(It.IsAny<PushoverPayload>()))
|
||||
.Callback<PushoverPayload>(payload => capturedPayload = payload)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _provider.SendNotificationAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedPayload);
|
||||
Assert.Equal("Test Description Only", capturedPayload.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static NotificationContext CreateTestContext()
|
||||
{
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.QueueItemDeleted,
|
||||
Title = "Test Notification",
|
||||
Description = "Test Description",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -399,13 +399,12 @@ public class QueueRuleMatchTests
|
||||
Assert.False(rule.MatchesTorrent(publicTorrent.Object));
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
|
||||
private static Mock<ITorrentItemWrapper> CreateTorrent(bool isPrivate, double completionPercentage, string size = "10 GB")
|
||||
{
|
||||
var torrent = new Mock<ITorrentItem>();
|
||||
var torrent = new Mock<ITorrentItemWrapper>();
|
||||
torrent.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrent.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrent.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrent.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
return torrent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Xunit;
|
||||
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Health;
|
||||
|
||||
public class ApplicationHealthCheckTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(healthCheck);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckHealthAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_DescriptionIndicatesRunning()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("running", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithCancellationToken_CompletesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!, cts.Token);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithContext_CompletesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
var context = new HealthCheckContext();
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_MultipleCalls_AllReturnHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var healthCheck = new ApplicationHealthCheck();
|
||||
|
||||
// Act
|
||||
var result1 = await healthCheck.CheckHealthAsync(null!);
|
||||
var result2 = await healthCheck.CheckHealthAsync(null!);
|
||||
var result3 = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result1.Status);
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result2.Status);
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result3.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Basic tests for DatabaseHealthCheck.
|
||||
/// Note: Full integration testing requires a real database since in-memory provider
|
||||
/// doesn't support migrations (GetPendingMigrationsAsync).
|
||||
/// </summary>
|
||||
public class DatabaseHealthCheckTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<DatabaseHealthCheck>> _loggerMock;
|
||||
private DataContext? _dataContext;
|
||||
|
||||
public DatabaseHealthCheckTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DatabaseHealthCheck>>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext?.Dispose();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_dataContext = new DataContext(options);
|
||||
|
||||
// Act
|
||||
var healthCheck = new DatabaseHealthCheck(_dataContext, _loggerMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(healthCheck);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exception Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenDisposedContext_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var disposedContext = new DataContext(options);
|
||||
disposedContext.Dispose();
|
||||
|
||||
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenUnhealthy_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var disposedContext = new DataContext(options);
|
||||
disposedContext.Dispose();
|
||||
|
||||
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
|
||||
|
||||
// Act
|
||||
await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenUnhealthy_DescriptionIndicatesFailure()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var disposedContext = new DataContext(options);
|
||||
disposedContext.Dispose();
|
||||
|
||||
var healthCheck = new DatabaseHealthCheck(disposedContext, _loggerMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("failed", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
|
||||
using HealthStatus = Cleanuparr.Infrastructure.Health.HealthStatus;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Health;
|
||||
|
||||
public class DownloadClientsHealthCheckTests
|
||||
{
|
||||
private readonly Mock<IHealthCheckService> _healthCheckServiceMock;
|
||||
private readonly Mock<ILogger<DownloadClientsHealthCheck>> _loggerMock;
|
||||
private readonly DownloadClientsHealthCheck _healthCheck;
|
||||
|
||||
public DownloadClientsHealthCheckTests()
|
||||
{
|
||||
_healthCheckServiceMock = new Mock<IHealthCheckService>();
|
||||
_loggerMock = new Mock<ILogger<DownloadClientsHealthCheck>>();
|
||||
_healthCheck = new DownloadClientsHealthCheck(_healthCheckServiceMock.Object, _loggerMock.Object);
|
||||
}
|
||||
|
||||
#region CheckHealthAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenNoClientsConfigured_ReturnsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(new Dictionary<Guid, HealthStatus>());
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
Assert.Contains("No download clients configured", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenAllClientsHealthy_ReturnsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client2") },
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client3") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
Assert.Contains("All 3 download clients are healthy", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenSomeClientsUnhealthy_ReturnsDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client2") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client3") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Degraded, result.Status);
|
||||
Assert.Contains("1/3", result.Description);
|
||||
Assert.Contains("Client3", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenMajorityUnhealthy_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client3") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("2/3", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenAllUnhealthy_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WhenServiceThrows_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Throws(new Exception("Service error"));
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("Download clients health check failed", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_IncludesUnhealthyClientNames()
|
||||
{
|
||||
// Arrange
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("HealthyClient") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient1") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient2") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("BrokenClient1", result.Description);
|
||||
Assert.Contains("BrokenClient2", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithSingleClient_HandlesCorrectly()
|
||||
{
|
||||
// Arrange - Single healthy client
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("OnlyClient") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithSingleUnhealthyClient_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange - Single unhealthy client (1/1 > 50%)
|
||||
var clients = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("BrokenClient") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.GetAllClientHealth())
|
||||
.Returns(clients);
|
||||
|
||||
// Act
|
||||
var result = await _healthCheck.CheckHealthAsync(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthCheckStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static HealthStatus CreateHealthyStatus(string clientName)
|
||||
{
|
||||
return new HealthStatus
|
||||
{
|
||||
IsHealthy = true,
|
||||
ClientName = clientName,
|
||||
ClientId = Guid.NewGuid(),
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ClientTypeName = DownloadClientTypeName.qBittorrent
|
||||
};
|
||||
}
|
||||
|
||||
private static HealthStatus CreateUnhealthyStatus(string clientName)
|
||||
{
|
||||
return new HealthStatus
|
||||
{
|
||||
IsHealthy = false,
|
||||
ClientName = clientName,
|
||||
ClientId = Guid.NewGuid(),
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ErrorMessage = "Connection failed",
|
||||
ClientTypeName = DownloadClientTypeName.qBittorrent
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Health;
|
||||
|
||||
public class HealthCheckBackgroundServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<HealthCheckBackgroundService>> _loggerMock;
|
||||
private readonly Mock<IHealthCheckService> _healthCheckServiceMock;
|
||||
private HealthCheckBackgroundService? _service;
|
||||
|
||||
public HealthCheckBackgroundServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<HealthCheckBackgroundService>>();
|
||||
_healthCheckServiceMock = new Mock<IHealthCheckService>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_service?.Dispose();
|
||||
}
|
||||
|
||||
private HealthCheckBackgroundService CreateService()
|
||||
{
|
||||
_service = new HealthCheckBackgroundService(
|
||||
_loggerMock.Object,
|
||||
_healthCheckServiceMock.Object);
|
||||
return _service;
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var service = CreateService();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenCancelledImmediately_StopsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_CallsCheckAllClientsHealthAsync()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
// Give it some time to execute at least once
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_healthCheckServiceMock.Verify(s => s.CheckAllClientsHealthAsync(), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenAllClientsHealthy_LogsDebugMessage()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client2") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Check that debug log was called (all healthy)
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("healthy")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenSomeClientsUnhealthy_LogsWarningMessage()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") },
|
||||
{ Guid.NewGuid(), CreateUnhealthyStatus("Client2", "Connection failed") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Check that warning log was called for unhealthy clients
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unhealthy")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenHealthCheckThrows_LogsErrorAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var callCount = 0;
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
throw new Exception("Health check failed");
|
||||
}
|
||||
return new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ Guid.NewGuid(), CreateHealthyStatus("Client1") }
|
||||
};
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Error should be logged
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error performing periodic health check")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithNoClients_HandlesEmptyResults()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>();
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Should handle gracefully
|
||||
_healthCheckServiceMock.Verify(s => s.CheckAllClientsHealthAsync(), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_LogsDetailedInfoForUnhealthyClients()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var unhealthyClientId = Guid.NewGuid();
|
||||
var healthResults = new Dictionary<Guid, HealthStatus>
|
||||
{
|
||||
{ unhealthyClientId, CreateUnhealthyStatus("UnhealthyClient", "Connection timeout") }
|
||||
};
|
||||
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(healthResults);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Should log details about the unhealthy client
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("UnhealthyClient") ||
|
||||
v.ToString()!.Contains("Connection timeout")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_StartsBackgroundService()
|
||||
{
|
||||
// Arrange
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(new Dictionary<Guid, HealthStatus>());
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
|
||||
// Cleanup
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_StopsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
_healthCheckServiceMock
|
||||
.Setup(s => s.CheckAllClientsHealthAsync())
|
||||
.ReturnsAsync(new Dictionary<Guid, HealthStatus>());
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Act
|
||||
cts.Cancel();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - Should log stop message
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopped")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static HealthStatus CreateHealthyStatus(string clientName)
|
||||
{
|
||||
return new HealthStatus
|
||||
{
|
||||
IsHealthy = true,
|
||||
ClientName = clientName,
|
||||
ClientId = Guid.NewGuid(),
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ResponseTime = TimeSpan.FromMilliseconds(50),
|
||||
ClientTypeName = DownloadClientTypeName.qBittorrent
|
||||
};
|
||||
}
|
||||
|
||||
private static HealthStatus CreateUnhealthyStatus(string clientName, string errorMessage)
|
||||
{
|
||||
return new HealthStatus
|
||||
{
|
||||
IsHealthy = false,
|
||||
ClientName = clientName,
|
||||
ClientId = Guid.NewGuid(),
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ResponseTime = TimeSpan.Zero,
|
||||
ErrorMessage = errorMessage,
|
||||
ClientTypeName = DownloadClientTypeName.qBittorrent
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// using Common.Configuration;
|
||||
// using Common.Enums;
|
||||
// using Infrastructure.Configuration;
|
||||
// using Infrastructure.Health;
|
||||
// using Infrastructure.Verticals.DownloadClient;
|
||||
// using Infrastructure.Verticals.DownloadClient.Factory;
|
||||
// using Microsoft.Extensions.Logging;
|
||||
// using NSubstitute;
|
||||
// using NSubstitute.ExceptionExtensions;
|
||||
//
|
||||
// namespace Infrastructure.Tests.Health;
|
||||
//
|
||||
// public class HealthCheckServiceFixture : IDisposable
|
||||
// {
|
||||
// public ILogger<HealthCheckService> Logger { get; }
|
||||
// public IConfigManager ConfigManager { get; }
|
||||
// public IDownloadClientFactory ClientFactory { get; }
|
||||
// public IDownloadService MockClient { get; }
|
||||
// public DownloadClientConfigs DownloadClientConfigs { get; }
|
||||
//
|
||||
// public HealthCheckServiceFixture()
|
||||
// {
|
||||
// Logger = Substitute.For<ILogger<HealthCheckService>>();
|
||||
// ConfigManager = Substitute.For<IConfigManager>();
|
||||
// ClientFactory = Substitute.For<IDownloadClientFactory>();
|
||||
// MockClient = Substitute.For<IDownloadService>();
|
||||
// Guid clientId = Guid.NewGuid();
|
||||
//
|
||||
// // Set up test download client config
|
||||
// DownloadClientConfigs = new DownloadClientConfigs
|
||||
// {
|
||||
// Clients = new List<DownloadClientConfig>
|
||||
// {
|
||||
// new()
|
||||
// {
|
||||
// Id = clientId,
|
||||
// Name = "Test QBittorrent",
|
||||
// Type = DownloadClientType.QBittorrent,
|
||||
// Enabled = true,
|
||||
// Username = "admin",
|
||||
// Password = "adminadmin"
|
||||
// },
|
||||
// new()
|
||||
// {
|
||||
// Id = Guid.NewGuid(),
|
||||
// Name = "Test Transmission",
|
||||
// Type = DownloadClientType.Transmission,
|
||||
// Enabled = true,
|
||||
// Username = "admin",
|
||||
// Password = "adminadmin"
|
||||
// },
|
||||
// new()
|
||||
// {
|
||||
// Id = Guid.NewGuid(),
|
||||
// Name = "Disabled Client",
|
||||
// Type = DownloadClientType.QBittorrent,
|
||||
// Enabled = false,
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// // Set up the mock client factory
|
||||
// ClientFactory.GetClient(Arg.Any<Guid>()).Returns(MockClient);
|
||||
// MockClient.GetClientId().Returns(clientId);
|
||||
//
|
||||
// // Set up mock config manager
|
||||
// ConfigManager.GetConfiguration<DownloadClientConfigs>().Returns(DownloadClientConfigs);
|
||||
// }
|
||||
//
|
||||
// public HealthCheckService CreateSut()
|
||||
// {
|
||||
// return new HealthCheckService(Logger, ConfigManager, ClientFactory);
|
||||
// }
|
||||
//
|
||||
// public void SetupHealthyClient(Guid clientId)
|
||||
// {
|
||||
// // Setup a client that will successfully login
|
||||
// MockClient.LoginAsync().Returns(Task.CompletedTask);
|
||||
// }
|
||||
//
|
||||
// public void SetupUnhealthyClient(Guid clientId, string errorMessage = "Failed to connect")
|
||||
// {
|
||||
// // Setup a client that will fail to login
|
||||
// MockClient.LoginAsync().Throws(new Exception(errorMessage));
|
||||
// }
|
||||
//
|
||||
// public void Dispose()
|
||||
// {
|
||||
// // Cleanup if needed
|
||||
// }
|
||||
// }
|
||||
@@ -1,177 +0,0 @@
|
||||
// using Infrastructure.Health;
|
||||
// using NSubstitute;
|
||||
// using Shouldly;
|
||||
//
|
||||
// namespace Infrastructure.Tests.Health;
|
||||
//
|
||||
// public class HealthCheckServiceTests : IClassFixture<HealthCheckServiceFixture>
|
||||
// {
|
||||
// private readonly HealthCheckServiceFixture _fixture;
|
||||
//
|
||||
// public HealthCheckServiceTests(HealthCheckServiceFixture fixture)
|
||||
// {
|
||||
// _fixture = fixture;
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task CheckClientHealthAsync_WithHealthyClient_ShouldReturnHealthyStatus()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Act
|
||||
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.IsHealthy.ShouldBeTrue(),
|
||||
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001")),
|
||||
// () => result.ErrorMessage.ShouldBeNull(),
|
||||
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task CheckClientHealthAsync_WithUnhealthyClient_ShouldReturnUnhealthyStatus()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000001"), "Connection refused");
|
||||
//
|
||||
// // Act
|
||||
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.IsHealthy.ShouldBeFalse(),
|
||||
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001")),
|
||||
// () => result.ErrorMessage?.ShouldContain("Connection refused"),
|
||||
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task CheckClientHealthAsync_WithNonExistentClient_ShouldReturnErrorStatus()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Configure the ConfigManager to return null for the client config
|
||||
// _fixture.ConfigManager.GetConfigurationAsync<DownloadClientConfigs>().Returns(
|
||||
// Task.FromResult<DownloadClientConfigs>(new())
|
||||
// );
|
||||
//
|
||||
// // Act
|
||||
// var result = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000010"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldSatisfyAllConditions(
|
||||
// () => result.IsHealthy.ShouldBeFalse(),
|
||||
// () => result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000010")),
|
||||
// () => result.ErrorMessage?.ShouldContain("not found"),
|
||||
// () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task CheckAllClientsHealthAsync_ShouldReturnAllEnabledClients()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
//
|
||||
// // Act
|
||||
// var results = await sut.CheckAllClientsHealthAsync();
|
||||
//
|
||||
// // Assert
|
||||
// results.Count.ShouldBe(2); // Only enabled clients
|
||||
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
// results[new Guid("00000000-0000-0000-0000-000000000001")].IsHealthy.ShouldBeTrue();
|
||||
// results[new Guid("00000000-0000-0000-0000-000000000002")].IsHealthy.ShouldBeFalse();
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task ClientHealthChanged_ShouldRaiseEventOnHealthStateChange()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// ClientHealthChangedEventArgs? capturedArgs = null;
|
||||
// sut.ClientHealthChanged += (_, args) => capturedArgs = args;
|
||||
//
|
||||
// // Act - first check establishes initial state
|
||||
// var firstResult = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Setup client to be unhealthy for second check
|
||||
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Act - second check changes state
|
||||
// var secondResult = await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// capturedArgs.ShouldNotBeNull();
|
||||
// capturedArgs.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// capturedArgs.Status.IsHealthy.ShouldBeFalse();
|
||||
// capturedArgs.IsDegraded.ShouldBeTrue();
|
||||
// capturedArgs.IsRecovered.ShouldBeFalse();
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task GetClientHealth_ShouldReturnCachedStatus()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Perform a check to cache the status
|
||||
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Act
|
||||
// var result = sut.GetClientHealth(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldNotBeNull();
|
||||
// result.IsHealthy.ShouldBeTrue();
|
||||
// result.ClientId.ShouldBe(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public void GetClientHealth_WithNoCheck_ShouldReturnNull()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
//
|
||||
// // Act
|
||||
// var result = sut.GetClientHealth(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
//
|
||||
// // Assert
|
||||
// result.ShouldBeNull();
|
||||
// }
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task GetAllClientHealth_ShouldReturnAllCheckedClients()
|
||||
// {
|
||||
// // Arrange
|
||||
// var sut = _fixture.CreateSut();
|
||||
// _fixture.SetupHealthyClient(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// _fixture.SetupUnhealthyClient(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
//
|
||||
// // Perform checks to cache statuses
|
||||
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// await sut.CheckClientHealthAsync(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
//
|
||||
// // Act
|
||||
// var results = sut.GetAllClientHealth();
|
||||
//
|
||||
// // Assert
|
||||
// results.Count.ShouldBe(2);
|
||||
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
// results.Keys.ShouldContain(new Guid("00000000-0000-0000-0000-000000000002"));
|
||||
// results[new Guid("00000000-0000-0000-0000-000000000001")].IsHealthy.ShouldBeTrue();
|
||||
// results[new Guid("00000000-0000-0000-0000-000000000002")].IsHealthy.ShouldBeFalse();
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,148 @@
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Models;
|
||||
|
||||
public class ValidationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_ReturnsValidResult()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Success();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_HasEmptyErrorMessage()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Success();
|
||||
|
||||
// Assert
|
||||
result.ErrorMessage.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_HasEmptyDetails()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Success();
|
||||
|
||||
// Assert
|
||||
result.Details.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_ReturnsInvalidResult()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Failure("Error occurred");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_ContainsErrorMessage()
|
||||
{
|
||||
// Arrange
|
||||
const string errorMessage = "Validation failed";
|
||||
|
||||
// Act
|
||||
var result = ValidationResult.Failure(errorMessage);
|
||||
|
||||
// Assert
|
||||
result.ErrorMessage.ShouldBe(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithDetails_ContainsAllDetails()
|
||||
{
|
||||
// Arrange
|
||||
const string errorMessage = "Multiple errors";
|
||||
var details = new List<string> { "Error 1", "Error 2", "Error 3" };
|
||||
|
||||
// Act
|
||||
var result = ValidationResult.Failure(errorMessage, details);
|
||||
|
||||
// Assert
|
||||
result.Details.Count.ShouldBe(3);
|
||||
result.Details.ShouldContain("Error 1");
|
||||
result.Details.ShouldContain("Error 2");
|
||||
result.Details.ShouldContain("Error 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithoutDetails_HasEmptyDetailsList()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Failure("Error");
|
||||
|
||||
// Assert
|
||||
result.Details.ShouldNotBeNull();
|
||||
result.Details.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithNullDetails_HasEmptyDetailsList()
|
||||
{
|
||||
// Act
|
||||
var result = ValidationResult.Failure("Error", null);
|
||||
|
||||
// Assert
|
||||
result.Details.ShouldNotBeNull();
|
||||
result.Details.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_IsValidIsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = new ValidationResult();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_ErrorMessageIsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = new ValidationResult();
|
||||
|
||||
// Assert
|
||||
result.ErrorMessage.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_DetailsIsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = new ValidationResult();
|
||||
|
||||
// Assert
|
||||
result.Details.ShouldNotBeNull();
|
||||
result.Details.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_CanBeSetDirectly()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ValidationResult();
|
||||
|
||||
// Act
|
||||
result.IsValid = true;
|
||||
result.ErrorMessage = "Test error";
|
||||
result.Details = new List<string> { "Detail 1" };
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.ErrorMessage.ShouldBe("Test error");
|
||||
result.Details.ShouldContain("Detail 1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Entities.AppStatus;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class AppStatusRefreshServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<AppStatusRefreshService>> _loggerMock;
|
||||
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly AppStatusSnapshot _snapshot;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private AppStatusRefreshService? _service;
|
||||
|
||||
public AppStatusRefreshServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<AppStatusRefreshService>>();
|
||||
_hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
_snapshot = new AppStatusSnapshot();
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
|
||||
// Setup hub context
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
var clientProxyMock = new Mock<IClientProxy>();
|
||||
clientsMock.Setup(c => c.All).Returns(clientProxyMock.Object);
|
||||
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_service?.Dispose();
|
||||
}
|
||||
|
||||
private AppStatusRefreshService CreateService()
|
||||
{
|
||||
_service = new AppStatusRefreshService(
|
||||
_loggerMock.Object,
|
||||
_hubContextMock.Object,
|
||||
_httpClientFactoryMock.Object,
|
||||
_snapshot,
|
||||
_jsonOptions);
|
||||
return _service;
|
||||
}
|
||||
|
||||
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Content = new StringContent(content, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(_httpHandlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsAllDependencies()
|
||||
{
|
||||
// Act
|
||||
var service = CreateService();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AppStatusSnapshot Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateLatestVersion_ChangesStatusReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
|
||||
// Act
|
||||
var result = snapshot.UpdateLatestVersion("1.0.0", out var status);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal("1.0.0", status.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateLatestVersion_SameVersionReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
snapshot.UpdateLatestVersion("1.0.0", out _);
|
||||
|
||||
// Act
|
||||
var result = snapshot.UpdateLatestVersion("1.0.0", out var status);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal("1.0.0", status.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateCurrentVersion_ChangesStatusReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
|
||||
// Act
|
||||
var result = snapshot.UpdateCurrentVersion("2.0.0", out var status);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal("2.0.0", status.CurrentVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_Current_ReturnsCurrentState()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
snapshot.UpdateCurrentVersion("1.0.0", out _);
|
||||
snapshot.UpdateLatestVersion("2.0.0", out _);
|
||||
|
||||
// Act
|
||||
var current = snapshot.Current;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("1.0.0", current.CurrentVersion);
|
||||
Assert.Equal("2.0.0", current.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateWithNull_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
snapshot.UpdateLatestVersion("1.0.0", out _);
|
||||
|
||||
// Act
|
||||
var result = snapshot.UpdateLatestVersion(null, out var status);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Null(status.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppStatusSnapshot_UpdateWithSameNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new AppStatusSnapshot();
|
||||
|
||||
// Act - Both are null initially
|
||||
var result = snapshot.UpdateLatestVersion(null, out _);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Quartz;
|
||||
using Quartz.Impl.Matchers;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class JobManagementServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<JobManagementService>> _loggerMock;
|
||||
private readonly Mock<ISchedulerFactory> _schedulerFactoryMock;
|
||||
private readonly Mock<IScheduler> _schedulerMock;
|
||||
private readonly Mock<IHubContext<AppHub>> _hubContextMock;
|
||||
private readonly JobManagementService _service;
|
||||
|
||||
public JobManagementServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<JobManagementService>>();
|
||||
_schedulerFactoryMock = new Mock<ISchedulerFactory>();
|
||||
_schedulerMock = new Mock<IScheduler>();
|
||||
_hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
|
||||
_schedulerFactoryMock.Setup(f => f.GetScheduler(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_schedulerMock.Object);
|
||||
|
||||
_service = new JobManagementService(_loggerMock.Object, _schedulerFactoryMock.Object, _hubContextMock.Object);
|
||||
}
|
||||
|
||||
#region StartJob Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WithInvalidDirectCronExpression_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var invalidCron = "invalid-cron";
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: invalidCron);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_JobDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("does not exist")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WithValidCronExpression_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
_schedulerMock.Verify(s => s.ResumeJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WithSchedule_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, schedule: schedule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WithNoScheduleOrCron_CreatesOneTimeTrigger()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.DownloadCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(
|
||||
It.Is<ITrigger>(t => t.Key.Name.Contains("onetime")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_CleansUpExistingTriggers_BeforeSchedulingNew()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var cronExpression = "0 0/5 * * * ?";
|
||||
|
||||
var existingTriggerMock = new Mock<ITrigger>();
|
||||
existingTriggerMock.Setup(t => t.Key).Returns(new TriggerKey("existing-trigger"));
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger> { existingTriggerMock.Object });
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.UnscheduleJob(
|
||||
It.Is<TriggerKey>(k => k.Name == "existing-trigger"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartJob_WhenSchedulerThrows_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var cronExpression = "0 0/5 * * * ?";
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.StartJob(jobType, directCronExpression: cronExpression);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StopJob Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StopJob_JobDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.StopJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopJob_JobExists_CleansUpTriggersAndReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
|
||||
var triggerMock = new Mock<ITrigger>();
|
||||
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
|
||||
|
||||
// Act
|
||||
var result = await _service.StopJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.UnscheduleJob(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopJob_WhenSchedulerThrows_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.StopJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetJob Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetJob_JobDoesNotExist_ReturnsNotFoundStatus()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Not Found", result.Status);
|
||||
Assert.Equal("QueueCleaner", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJob_JobExistsNoTriggers_ReturnsNotScheduledStatus()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Not Scheduled", result.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TriggerState.Normal, "Scheduled")]
|
||||
[InlineData(TriggerState.Paused, "Paused")]
|
||||
[InlineData(TriggerState.Complete, "Complete")]
|
||||
[InlineData(TriggerState.Error, "Error")]
|
||||
[InlineData(TriggerState.Blocked, "Running")]
|
||||
[InlineData(TriggerState.None, "Not Scheduled")]
|
||||
public async Task GetJob_WithTrigger_ReturnsCorrectStatus(TriggerState triggerState, string expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
var triggerMock = new Mock<ITrigger>();
|
||||
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
|
||||
triggerMock.Setup(t => t.GetNextFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
triggerMock.Setup(t => t.GetPreviousFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(-5));
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
|
||||
_schedulerMock.Setup(s => s.GetTriggerState(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(triggerState);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedStatus, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJob_WhenSchedulerThrows_ReturnsErrorStatus()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJob(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error", result.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllJobs Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllJobs_NoJobs_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAllJobs();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllJobs_WithJobs_ReturnsJobList()
|
||||
{
|
||||
// Arrange
|
||||
var jobKey = new JobKey("QueueCleaner");
|
||||
var triggerMock = new Mock<ITrigger>();
|
||||
triggerMock.Setup(t => t.Key).Returns(new TriggerKey("test-trigger"));
|
||||
triggerMock.Setup(t => t.GetNextFireTimeUtc()).Returns(DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
|
||||
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "DEFAULT" });
|
||||
_schedulerMock.Setup(s => s.GetJobKeys(It.IsAny<GroupMatcher<JobKey>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new HashSet<JobKey> { jobKey });
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger> { triggerMock.Object });
|
||||
_schedulerMock.Setup(s => s.GetTriggerState(It.IsAny<TriggerKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TriggerState.Normal);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAllJobs();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("QueueCleaner", result[0].Name);
|
||||
Assert.Equal("Scheduled", result[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllJobs_WhenSchedulerThrows_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
_schedulerMock.Setup(s => s.GetJobGroupNames(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetAllJobs();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TriggerJobOnce Tests
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobOnce_JobDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.TriggerJobOnce(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobOnce_JobExists_TriggersJobAndReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.TriggerJobOnce(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(
|
||||
It.Is<ITrigger>(t => t.Key.Name.Contains("immediate") && t.Key.Name.Contains("manual")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobOnce_WhenSchedulerThrows_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.TriggerJobOnce(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateJobSchedule Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateJobSchedule_NullSchedule_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => _service.UpdateJobSchedule(jobType, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateJobSchedule_JobDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateJobSchedule(jobType, schedule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateJobSchedule_ValidSchedule_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.DownloadCleaner;
|
||||
var schedule = new JobSchedule { Every = 10, Type = ScheduleUnit.Minutes };
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTriggersOfJob(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ITrigger>());
|
||||
_schedulerMock.Setup(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DateTimeOffset.Now);
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateJobSchedule(jobType, schedule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
_schedulerMock.Verify(s => s.ScheduleJob(It.IsAny<ITrigger>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateJobSchedule_WhenSchedulerThrows_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes };
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateJobSchedule(jobType, schedule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetMainTrigger Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_TriggerExists_ReturnsTrigger()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger");
|
||||
|
||||
var triggerMock = new Mock<ITrigger>();
|
||||
triggerMock.Setup(t => t.Key).Returns(expectedTriggerKey);
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_schedulerMock.Setup(s => s.GetTrigger(expectedTriggerKey, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(triggerMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedTriggerKey, result.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_schedulerMock.Setup(s => s.CheckExists(It.IsAny<JobKey>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public class RuleEvaluatorTests
|
||||
};
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -56,13 +56,12 @@ public class RuleEvaluatorTests
|
||||
|
||||
long downloadedBytes = 0;
|
||||
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
var torrentMock = new Mock<ITorrentItemWrapper>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns("hash");
|
||||
torrentMock.SetupGet(t => t.Name).Returns("Example Torrent");
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(false);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse("100 MB").Bytes);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
|
||||
|
||||
// Seed cache with initial observation (no reset expected)
|
||||
@@ -91,7 +90,7 @@ public class RuleEvaluatorTests
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns((StallRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -115,7 +114,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -144,7 +143,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -172,7 +171,7 @@ public class RuleEvaluatorTests
|
||||
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
@@ -197,7 +196,7 @@ public class RuleEvaluatorTests
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns((SlowRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -221,7 +220,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -249,7 +248,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -277,7 +276,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -304,7 +303,7 @@ public class RuleEvaluatorTests
|
||||
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(failingRule);
|
||||
|
||||
strikerMock
|
||||
@@ -336,7 +335,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -374,7 +373,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 2);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -408,7 +407,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -437,7 +436,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -469,7 +468,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -497,7 +496,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 2);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -525,7 +524,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 0);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -559,7 +558,7 @@ public class RuleEvaluatorTests
|
||||
maxTimeHours: 1);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -588,7 +587,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -620,7 +619,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -644,7 +643,7 @@ public class RuleEvaluatorTests
|
||||
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrentMock(
|
||||
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
|
||||
Func<long>? downloadedBytesFactory = null,
|
||||
bool isPrivate = false,
|
||||
string hash = "hash",
|
||||
@@ -652,13 +651,12 @@ public class RuleEvaluatorTests
|
||||
double completionPercentage = 50,
|
||||
string size = "100 MB")
|
||||
{
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
var torrentMock = new Mock<ITorrentItemWrapper>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns(hash);
|
||||
torrentMock.SetupGet(t => t.Name).Returns(name);
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytesFactory?.Invoke() ?? 0);
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(7200);
|
||||
@@ -720,7 +718,7 @@ public class RuleEvaluatorTests
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns((StallRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -745,7 +743,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -774,7 +772,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -803,7 +801,7 @@ public class RuleEvaluatorTests
|
||||
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(stallRule);
|
||||
|
||||
strikerMock
|
||||
@@ -830,7 +828,7 @@ public class RuleEvaluatorTests
|
||||
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns((SlowRule?)null);
|
||||
|
||||
var torrentMock = CreateTorrentMock();
|
||||
@@ -855,7 +853,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -884,7 +882,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -919,7 +917,7 @@ public class RuleEvaluatorTests
|
||||
deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
@@ -949,7 +947,7 @@ public class RuleEvaluatorTests
|
||||
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
|
||||
|
||||
ruleManagerMock
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItem>()))
|
||||
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
|
||||
.Returns(slowRule);
|
||||
|
||||
strikerMock
|
||||
|
||||
@@ -379,18 +379,17 @@ public class RuleManagerTests
|
||||
Assert.Equal(slowRule.Id, result.Id);
|
||||
}
|
||||
|
||||
private static Mock<ITorrentItem> CreateTorrentMock(
|
||||
private static Mock<ITorrentItemWrapper> CreateTorrentMock(
|
||||
bool isPrivate = false,
|
||||
double completionPercentage = 50,
|
||||
string size = "100 MB")
|
||||
{
|
||||
var torrentMock = new Mock<ITorrentItem>();
|
||||
var torrentMock = new Mock<ITorrentItemWrapper>();
|
||||
torrentMock.SetupGet(t => t.Hash).Returns("test-hash");
|
||||
torrentMock.SetupGet(t => t.Name).Returns("Test Torrent");
|
||||
torrentMock.SetupGet(t => t.IsPrivate).Returns(isPrivate);
|
||||
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(completionPercentage);
|
||||
torrentMock.SetupGet(t => t.Size).Returns(ByteSize.Parse(size).Bytes);
|
||||
torrentMock.SetupGet(t => t.Trackers).Returns(Array.Empty<string>());
|
||||
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(0);
|
||||
torrentMock.SetupGet(t => t.DownloadSpeed).Returns(0);
|
||||
torrentMock.SetupGet(t => t.Eta).Returns(3600);
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Services;
|
||||
|
||||
public class StrikerTests : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<Striker> _logger;
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly Striker _striker;
|
||||
|
||||
public StrikerTests()
|
||||
{
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_logger = Substitute.For<ILogger<Striker>>();
|
||||
|
||||
// Create EventPublisher with mocked dependencies
|
||||
var eventsContext = CreateMockEventsContext();
|
||||
var hubContext = Substitute.For<IHubContext<AppHub>>();
|
||||
var hubClients = Substitute.For<IHubClients>();
|
||||
var clientProxy = Substitute.For<IClientProxy>();
|
||||
hubContext.Clients.Returns(hubClients);
|
||||
hubClients.All.Returns(clientProxy);
|
||||
|
||||
var eventLogger = Substitute.For<ILogger<EventPublisher>>();
|
||||
var notificationPublisher = Substitute.For<INotificationPublisher>();
|
||||
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
|
||||
// Configure dry run interceptor to just complete the task (we don't need actual DB saves in tests)
|
||||
dryRunInterceptor
|
||||
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_eventPublisher = new EventPublisher(
|
||||
eventsContext,
|
||||
hubContext,
|
||||
eventLogger,
|
||||
notificationPublisher,
|
||||
dryRunInterceptor);
|
||||
|
||||
_striker = new Striker(_logger, _cache, _eventPublisher);
|
||||
|
||||
// Clear static state before each test
|
||||
Striker.RecurringHashes.Clear();
|
||||
|
||||
// Set up required context for recurring item events and FailedImport strikes
|
||||
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
|
||||
ContextProvider.Set("ArrInstanceUrl", new Uri("http://localhost:8989"));
|
||||
ContextProvider.Set(new QueueRecord
|
||||
{
|
||||
Title = "Test Item",
|
||||
DownloadId = "test-download-id",
|
||||
Protocol = "torrent",
|
||||
Id = 1,
|
||||
StatusMessages = []
|
||||
});
|
||||
}
|
||||
|
||||
private static EventsContext CreateMockEventsContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new EventsContext(options);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_FirstStrike_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 3;
|
||||
|
||||
// Act
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_ReachesMaxStrikes_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 3;
|
||||
|
||||
// Act - Strike 3 times
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_ExceedsMaxStrikes_ReturnsTrue_AndAddsToRecurringHashes()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "ABC123";
|
||||
const string itemName = "Recurring Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Act - Strike 3 times (exceeds max of 2)
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_DifferentStrikeTypes_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Act - Strike with different types
|
||||
var stalledResult1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var slowSpeedResult1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
|
||||
var stalledResult2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var slowSpeedResult2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
|
||||
|
||||
// Assert - Both should reach max independently
|
||||
stalledResult1.ShouldBeFalse();
|
||||
slowSpeedResult1.ShouldBeFalse();
|
||||
stalledResult2.ShouldBeTrue(); // 2nd stalled strike = maxStrikes
|
||||
slowSpeedResult2.ShouldBeTrue(); // 2nd slow speed strike = maxStrikes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_SameHash_AccumulatesStrikes()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 5;
|
||||
|
||||
// Act - Strike 4 times
|
||||
var result1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result3 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var result4 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert - None should trigger removal yet (need 5)
|
||||
result1.ShouldBeFalse();
|
||||
result2.ShouldBeFalse();
|
||||
result3.ShouldBeFalse();
|
||||
result4.ShouldBeFalse();
|
||||
|
||||
// 5th strike should trigger removal
|
||||
var result5 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
result5.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStrikeAsync_ClearsStrikeCount()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 3;
|
||||
|
||||
// Strike twice
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Act - Reset strikes
|
||||
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled);
|
||||
|
||||
// Assert - Next strike should be treated as first (returns false)
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStrikeAsync_OnlyResetsSpecifiedType()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Strike with both types
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
|
||||
|
||||
// Act - Reset only Stalled strikes
|
||||
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled);
|
||||
|
||||
// Assert - Stalled should be reset (1st strike = false), SlowSpeed should continue (2nd strike = true)
|
||||
var stalledResult = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var slowSpeedResult = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.SlowSpeed);
|
||||
|
||||
stalledResult.ShouldBeFalse(); // Reset, so this is strike #1
|
||||
slowSpeedResult.ShouldBeTrue(); // Not reset, so this is strike #2 = maxStrikes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_ZeroMaxStrikes_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "abc123";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 0;
|
||||
|
||||
// Act
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert - Should return false immediately without striking
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)2, 0, false)] // Strike 1, max 2 -> below limit (1 < 2)
|
||||
[InlineData((ushort)2, 1, true)] // Strike 2, max 2 -> at limit (2 >= 2)
|
||||
[InlineData((ushort)3, 1, false)] // Strike 2, max 3 -> below limit (2 < 3)
|
||||
[InlineData((ushort)3, 2, true)] // Strike 3, max 3 -> at limit (3 >= 3)
|
||||
[InlineData((ushort)1, 0, true)] // Strike 1, max 1 -> at limit (1 >= 1)
|
||||
public async Task StrikeAndCheckLimit_BoundaryConditions(ushort maxStrikes, int preStrikes, bool expectedResult)
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "boundary-test";
|
||||
const string itemName = "Boundary Test Item";
|
||||
|
||||
// Pre-strike
|
||||
for (int i = 0; i < preStrikes; i++)
|
||||
{
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expectedResult);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StrikeType.Stalled)]
|
||||
[InlineData(StrikeType.DownloadingMetadata)]
|
||||
[InlineData(StrikeType.FailedImport)]
|
||||
[InlineData(StrikeType.SlowSpeed)]
|
||||
[InlineData(StrikeType.SlowTime)]
|
||||
public async Task StrikeAndCheckLimit_AllStrikeTypes_WorkCorrectly(StrikeType strikeType)
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "type-test";
|
||||
const string itemName = "Type Test Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Act
|
||||
var result1 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, strikeType);
|
||||
var result2 = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, strikeType);
|
||||
|
||||
// Assert
|
||||
result1.ShouldBeFalse();
|
||||
result2.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_DifferentHashes_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
const string hash1 = "hash1";
|
||||
const string hash2 = "hash2";
|
||||
const string itemName = "Test Item";
|
||||
const ushort maxStrikes = 2;
|
||||
|
||||
// Act - Strike hash1 twice, hash2 once
|
||||
await _striker.StrikeAndCheckLimit(hash1, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash2, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var hash1Result = await _striker.StrikeAndCheckLimit(hash1, itemName, maxStrikes, StrikeType.Stalled);
|
||||
var hash2Result = await _striker.StrikeAndCheckLimit(hash2, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert
|
||||
hash1Result.ShouldBeTrue(); // hash1 reached max (2 strikes)
|
||||
hash2Result.ShouldBeTrue(); // hash2 reached max (2 strikes)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStrikeAsync_NonExistentStrike_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "never-struck";
|
||||
const string itemName = "Never Struck Item";
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrikeAndCheckLimit_RecurringItem_OnlyAddedOnceToRecurringHashes()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "recurring-hash";
|
||||
const string itemName = "Recurring Item";
|
||||
const ushort maxStrikes = 1;
|
||||
|
||||
// Act - Strike multiple times past the limit
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
|
||||
|
||||
// Assert - Hash should only appear once in RecurringHashes
|
||||
Striker.RecurringHashes.Count.ShouldBe(1);
|
||||
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Utilities;
|
||||
|
||||
public class CronExpressionConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConvertToCronExpression_Seconds_ReturnsCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = 30, Type = ScheduleUnit.Seconds };
|
||||
|
||||
// Act
|
||||
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe("0/30 * * ? * * *");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "0 0/1 * ? * * *")]
|
||||
[InlineData(5, "0 0/5 * ? * * *")]
|
||||
[InlineData(10, "0 0/10 * ? * * *")]
|
||||
[InlineData(15, "0 0/15 * ? * * *")]
|
||||
[InlineData(30, "0 0/30 * ? * * *")]
|
||||
public void ConvertToCronExpression_Minutes_ReturnsCorrectFormat(int minutes, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = minutes, Type = ScheduleUnit.Minutes };
|
||||
|
||||
// Act
|
||||
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "0 0 0/1 ? * * *")]
|
||||
[InlineData(2, "0 0 0/2 ? * * *")]
|
||||
[InlineData(4, "0 0 0/4 ? * * *")]
|
||||
[InlineData(6, "0 0 0/6 ? * * *")]
|
||||
[InlineData(12, "0 0 0/12 ? * * *")]
|
||||
public void ConvertToCronExpression_Hours_ReturnsCorrectFormat(int hours, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = hours, Type = ScheduleUnit.Hours };
|
||||
|
||||
// Act
|
||||
var result = CronExpressionConverter.ConvertToCronExpression(schedule);
|
||||
|
||||
// Assert
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0 */5 * * * ?")]
|
||||
[InlineData("0 0 */2 * * ?")]
|
||||
[InlineData("0/30 * * ? * * *")]
|
||||
public void IsValidCronExpression_ValidQuartzCron_ReturnsTrue(string cronExpression)
|
||||
{
|
||||
// Act
|
||||
var result = CronExpressionConverter.IsValidCronExpression(cronExpression);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("* * *")]
|
||||
[InlineData("not a cron")]
|
||||
[InlineData("0 0 0 0 0 0 0")]
|
||||
public void IsValidCronExpression_InvalidCron_ReturnsFalse(string cronExpression)
|
||||
{
|
||||
// Act
|
||||
var result = CronExpressionConverter.IsValidCronExpression(cronExpression);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
[InlineData(6)]
|
||||
[InlineData(10)]
|
||||
[InlineData(12)]
|
||||
[InlineData(15)]
|
||||
[InlineData(20)]
|
||||
[InlineData(30)]
|
||||
public void ConvertToCronExpression_AllValidMinuteValues_Succeeds(int minutes)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = minutes, Type = ScheduleUnit.Minutes };
|
||||
|
||||
// Act & Assert
|
||||
Should.NotThrow(() => CronExpressionConverter.ConvertToCronExpression(schedule));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(6)]
|
||||
[InlineData(8)]
|
||||
[InlineData(12)]
|
||||
public void ConvertToCronExpression_AllValidHourValues_Succeeds(int hours)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = hours, Type = ScheduleUnit.Hours };
|
||||
|
||||
// Act & Assert
|
||||
Should.NotThrow(() => CronExpressionConverter.ConvertToCronExpression(schedule));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7, ScheduleUnit.Minutes)] // 7 doesn't divide 60 evenly
|
||||
[InlineData(45, ScheduleUnit.Minutes)] // 45 is not in the valid list
|
||||
[InlineData(5, ScheduleUnit.Hours)] // 5 doesn't divide 24 evenly
|
||||
[InlineData(7, ScheduleUnit.Hours)] // 7 doesn't divide 24 evenly
|
||||
[InlineData(15, ScheduleUnit.Seconds)] // Only 30 seconds is valid
|
||||
public void ConvertToCronExpression_InvalidValue_ThrowsValidationException(int value, ScheduleUnit unit)
|
||||
{
|
||||
// Arrange
|
||||
var schedule = new JobSchedule { Every = value, Type = unit };
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ValidationException>(() => CronExpressionConverter.ConvertToCronExpression(schedule));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
[InlineData("0 0 0 32 1 ?")] // Invalid day of month (32)
|
||||
[InlineData("0 0 0 ? 13 *")] // Invalid month (13)
|
||||
[InlineData("0 60 * ? * *")] // Invalid minute (60)
|
||||
[InlineData("0 0 25 ? * *")] // Invalid hour (25)
|
||||
[InlineData("0 0 0 ? * 8")] // Invalid day of week (8)
|
||||
public void IsValidCronExpression_InvalidInput_ReturnsFalse(string? cronExpression)
|
||||
{
|
||||
// Act
|
||||
var result = CronExpressionConverter.IsValidCronExpression(cronExpression!);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToCronExpression_NullSchedule_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => CronExpressionConverter.ConvertToCronExpression(null!));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user