Compare commits

..

36 Commits

Author SHA1 Message Date
Flaminel
573dbcf882 Fix modal transparency (#448) 2026-02-17 00:23:31 +02:00
Flaminel
94acd9afa4 Fix download client inputs (#442) 2026-02-15 04:06:28 +02:00
Flaminel
65d25a72a9 Fix failed import reason display for events (#443) 2026-02-15 03:59:40 +02:00
Flaminel
97eb2fce44 Add strikes page (#438) 2026-02-15 03:57:14 +02:00
Flaminel
701829001c Fix speed and size inputs for queue rules (#440) 2026-02-15 01:19:32 +02:00
Flaminel
8aeeca111c Add strike persistency (#437) 2026-02-14 04:00:05 +02:00
Flaminel
c43936ce81 Remove testing download path (#436) 2026-02-13 11:52:53 +02:00
Flaminel
f35eb0c922 Add full message for logs on the UI (#435) 2026-02-13 02:05:39 +02:00
Flaminel
b2b0626b44 Add external url for notifications (#434) 2026-02-13 02:04:27 +02:00
Flaminel
40f108d7ca Add app status setting to general settings (#433) 2026-02-12 23:12:26 +02:00
Flaminel
6570f74b7e Fix Deluge failing on empty password (#432) 2026-02-12 22:29:55 +02:00
Flaminel
16f216cf84 Add Gotify notification provider (#420) 2026-02-12 18:17:09 +02:00
Flaminel
69551edeff Revamp UI (#429) 2026-02-12 17:51:30 +02:00
Flaminel
7192796e89 Update frontend packages (#422) 2026-01-14 14:17:04 +02:00
Flaminel
1d1ee7972f Use CDN to deliver logos (#421) 2026-01-14 13:51:25 +02:00
Flaminel
8bd6b86018 Add Discord notification provider (#417) 2026-01-13 18:53:40 +02:00
Flaminel
6abb542271 Fix Servarr version dropdown (#414) 2026-01-11 02:33:16 +02:00
Flaminel
2aceae3078 Fix package lock file being out of sync (#411) 2026-01-09 00:05:34 +02:00
Flaminel
65b200a68e Fix MacOS build (#409) 2026-01-07 02:57:52 +02:00
Flaminel
de0c881944 Fix inode count duplication when looking for hardlinks (#407) 2026-01-07 02:27:27 +02:00
Flaminel
d0ef01d79b Add images for Apprise CLI notifications (#408) 2026-01-07 02:11:59 +02:00
Flaminel
9457236e99 Update packages and dotnet version (#398) 2026-01-07 00:27:20 +02:00
Flaminel
d43b4fc1c4 Add Whisparr v3 support (#405) 2026-01-03 23:34:21 +02:00
Flaminel
e9750429eb Add Telegram notification provider (#400) 2026-01-03 23:34:05 +02:00
Flaminel
b71b268b08 Add configurable bind address (#404) 2025-12-31 04:40:43 +02:00
Flaminel
a708d22b27 Add GitAds to README (#403) 2025-12-31 03:35:35 +02:00
Flaminel
a9a3b08ad6 Add item name for dashboard events (#402) 2025-12-31 02:35:55 +02:00
Flaminel
1d1e8679e4 Add supported apps version disclaimer (#399) 2025-12-30 00:26:16 +02:00
Flaminel
142d445ed0 Add Apprise CLI notification provider (#387) 2025-12-25 22:05:23 +02:00
Flaminel
375094862c Add test button for arrs and download clients (#391) 2025-12-20 17:06:03 +02:00
Flaminel
58a72cef0f Add option for multiple ignored root directories (#390) 2025-12-20 17:04:36 +02:00
Flaminel
4ceff127a7 Add option to keep source files when cleaning downloads (#388) 2025-12-19 23:52:59 +02:00
Flaminel
c07b811cf8 Fix Transmission torrent fetch (#389) 2025-12-19 23:35:23 +02:00
Flaminel
b16fa70774 Add Pushover notification provider (#385) 2025-12-13 21:24:34 +02:00
Flaminel
b343165644 Fix Download Cleaner making too many requests (#368) 2025-12-10 09:22:51 +02:00
Flaminel
02dff0bb9b Fix manual release workflows (#380) 2025-12-01 23:42:22 +02:00
735 changed files with 71907 additions and 41861 deletions

View File

@@ -11,6 +11,11 @@ on:
type: boolean
required: false
default: true
app_version:
description: 'Application version'
type: string
required: false
default: ''
# Cancel in-progress runs for the same PR
concurrency:
@@ -34,18 +39,32 @@ jobs:
timeout-minutes: 1
run: |
githubHeadRef=${{ env.githubHeadRef }}
inputVersion="${{ inputs.app_version }}"
latestDockerTag=""
versionDockerTag=""
majorVersionDockerTag=""
minorVersionDockerTag=""
version="0.0.1"
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
if [[ -n "$inputVersion" ]]; then
# Version provided via input (manual release)
branch="main"
latestDockerTag="latest"
versionDockerTag="$inputVersion"
version="$inputVersion"
# Extract major and minor versions for additional tags
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
majorVersionDockerTag="${BASH_REMATCH[1]}"
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
elif [[ "$githubRef" =~ ^"refs/tags/" ]]; then
# Tag push
branch=${githubRef##*/}
latestDockerTag="latest"
versionDockerTag=${branch#v}
version=${branch#v}
# Extract major and minor versions for additional tags
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
majorVersionDockerTag="${BASH_REMATCH[1]}"
@@ -136,12 +155,10 @@ jobs:
VERSION=${{ env.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
outputs: |
type=image
platforms: |
linux/amd64
linux/arm64
push: ${{ inputs.push_docker }}
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
tags: |
${{ env.githubTags }}
# Enable BuildKit cache for faster builds

View File

@@ -76,7 +76,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Cache NuGet packages
uses: actions/cache@v4
@@ -124,27 +124,3 @@ jobs:
name: executable-${{ matrix.platform }}
path: ./artifacts/*.zip
retention-days: 30
# Consolidate all executable artifacts
consolidate:
needs: build-platform
runs-on: ubuntu-latest
steps:
- name: Download all platform artifacts
uses: actions/download-artifact@v4
with:
pattern: executable-*
path: ./artifacts
merge-multiple: true
- name: List downloaded artifacts
run: |
echo "Consolidated executable artifacts:"
find ./artifacts -type f -name "*.zip" | sort
- name: Upload consolidated artifacts
uses: actions/upload-artifact@v4
with:
name: cleanuparr-executables
path: ./artifacts/*.zip
retention-days: 30

View File

@@ -21,12 +21,12 @@ jobs:
matrix:
include:
- arch: Intel
runner: macos-13
runner: macos-15-intel
runtime: osx-x64
min_os_version: "10.15"
artifact_suffix: intel
- arch: ARM
runner: macos-14
runner: macos-15
runtime: osx-arm64
min_os_version: "11.0"
artifact_suffix: arm64
@@ -86,7 +86,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Restore .NET dependencies
run: |
@@ -350,8 +350,8 @@ jobs:
# Copy uninstall script to app bundle for user access
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
# Determine package name
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Determine package name - if app_version input was provided, it's a release build
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
else
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"

View File

@@ -70,7 +70,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Restore .NET dependencies
run: |

View File

@@ -8,8 +8,7 @@ on:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
required: false
default: ''
required: true
runTests:
description: 'Run test suite'
type: boolean
@@ -57,25 +56,38 @@ jobs:
release_version=${GITHUB_REF##refs/tags/}
app_version=${release_version#v}
is_tag=true
elif [[ -n "${{ github.event.inputs.version }}" ]]; then
else
# Manual workflow with version
app_version="${{ github.event.inputs.version }}"
release_version="v$app_version"
is_tag=false
else
# Manual workflow without version
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
# Validate version format (x.x.x)
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
echo "Provided version: $app_version"
exit 1
fi
release_version="v$app_version"
is_tag=false
fi
echo "app_version=$app_version" >> $GITHUB_OUTPUT
echo "release_version=$release_version" >> $GITHUB_OUTPUT
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
echo "🏷️ Release Version: $release_version"
echo "📱 App Version: $app_version"
echo "🔖 Is Tag: $is_tag"
echo "Release Version: $release_version"
echo "App Version: $app_version"
echo "Is Tag: $is_tag"
- name: Check if release already exists
run: |
if gh release view "${{ steps.version.outputs.release_version }}" &>/dev/null; then
echo "❌ Release ${{ steps.version.outputs.release_version }} already exists. Stopping workflow."
exit 1
fi
echo "✅ Release ${{ steps.version.outputs.release_version }} does not exist. Proceeding."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Run tests
test:
@@ -148,6 +160,7 @@ jobs:
uses: ./.github/workflows/build-docker.yml
with:
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Create GitHub release
@@ -176,15 +189,32 @@ jobs:
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
- name: Download all artifacts
- name: Download executable artifacts
uses: actions/download-artifact@v4
with:
pattern: executable-*
path: ./artifacts
merge-multiple: true
- name: Download Windows installer
uses: actions/download-artifact@v4
with:
name: Cleanuparr-windows-installer
path: ./artifacts
- name: Download macOS installers
uses: actions/download-artifact@v4
with:
pattern: Cleanuparr-macos-*-installer
path: ./artifacts
merge-multiple: true
- name: List downloaded artifacts
run: |
echo "📦 Downloaded artifacts:"
find ./artifacts -type f -name "*.zip" -o -name "*.pkg" -o -name "*.exe" | sort
echo "Downloaded artifacts:"
find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | sort
echo ""
echo "Total files: $(find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | wc -l)"
- name: Create release
uses: softprops/action-gh-release@v2
@@ -196,9 +226,9 @@ jobs:
target_commitish: main
generate_release_notes: true
files: |
./artifacts/**/*.zip
./artifacts/**/*.pkg
./artifacts/**/*.exe
./artifacts/*.zip
./artifacts/*.pkg
./artifacts/*.exe
# Summary job
summary:

View File

@@ -31,7 +31,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Cache NuGet packages
uses: actions/cache@v4
@@ -61,7 +61,7 @@ jobs:
- name: Run tests
id: run-tests
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --results-directory ./coverage
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage
- name: Upload test results
uses: actions/upload-artifact@v4

350
CLAUDE.md Normal file
View File

@@ -0,0 +1,350 @@
# Cleanuparr - Claude AI Rules
## 🚨 Critical Guidelines
**READ THIS FIRST:**
1. ⚠️ **DO NOT break existing functionality** - All features are critical and must continue to work
2.**When in doubt, ASK** - Always clarify before implementing uncertain changes
3. 📋 **Follow existing patterns** - Study the codebase style before making changes
4. 🆕 **Ask before introducing new patterns** - Use current coding standards or get approval first
## Project Overview
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, and µTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
**Key Features:**
- Strike system for bad downloads
- Malware detection and blocking
- Automatic search triggering after removal
- Orphaned download cleanup with cross-seed support
- Support for multiple notification providers (Discord, etc.)
## Architecture & Tech Stack
### Backend
- **.NET 10.0** (C#) with ASP.NET Core
- **Architecture**: Clean Architecture pattern
- `Cleanuparr.Domain` - Domain models and business logic
- `Cleanuparr.Application` - Application services and use cases
- `Cleanuparr.Infrastructure` - External integrations (*arr apps, download clients)
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
- `Cleanuparr.Api` - REST API and web host
- `Cleanuparr.Shared` - Shared utilities
- **Database**: SQLite with Entity Framework Core 10.0
- Two separate contexts: `DataContext` and `EventsContext`
- **Key Libraries**:
- MassTransit (messaging)
- Quartz.NET (scheduling)
- Serilog (logging)
- SignalR (real-time communication)
### Frontend
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **UI**: Custom glassmorphism design system (no external UI frameworks)
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
- **Design System**: 3-layer SCSS (`_variables``_tokens``_themes`), dark/light themes
- **State Management**: @ngrx/signals (Angular signals-based)
- **Real-time Updates**: SignalR (@microsoft/signalr)
- **PWA**: Service Worker support enabled
### Documentation
- **Docusaurus** (TypeScript-based static site)
- Hosted at https://cleanuparr.github.io/Cleanuparr/
### Deployment
- **Docker** (primary distribution method)
- Standalone executables for Windows, macOS, and Linux
- Platform installers for Windows (.exe) and macOS (.pkg)
## Development Setup
### Prerequisites
- .NET 10.0 SDK
- Node.js 18+
- Git
- (Optional) Make for database migrations
- (Optional) JetBrains Rider or Visual Studio
### GitHub Packages Authentication
Cleanuparr uses GitHub Packages for NuGet dependencies. Configure access:
```bash
dotnet nuget add source \
--username YOUR_GITHUB_USERNAME \
--password YOUR_GITHUB_PAT \
--store-password-in-clear-text \
--name Cleanuparr \
https://nuget.pkg.github.com/Cleanuparr/index.json
```
You need a GitHub PAT with `read:packages` permission.
### Running the Backend
```bash
cd code/backend
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
```
API runs at http://localhost:5000
### Running the Frontend
```bash
cd code/frontend
npm install
npm start
```
UI runs at http://localhost:4200
### Running Tests
```bash
cd code/backend
dotnet test
```
### Running Documentation
```bash
cd docs
npm install
npm start
```
Docs run at http://localhost:3000
## Project Structure
```
Cleanuparr/
├── code/
│ ├── backend/
│ │ ├── Cleanuparr.Api/ # API entry point
│ │ ├── Cleanuparr.Application/ # Business logic layer
│ │ ├── Cleanuparr.Domain/ # Domain models
│ │ ├── Cleanuparr.Infrastructure/ # External integrations
│ │ ├── Cleanuparr.Persistence/ # Database & EF Core
│ │ ├── Cleanuparr.Shared/ # Shared utilities
│ │ └── *.Tests/ # Unit tests
│ ├── frontend/ # Angular 21 application
│ ├── ui/ # Built frontend assets
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
│ └── Makefile # Build & migration helpers
├── docs/ # Docusaurus documentation
├── Logo/ # Branding assets
├── .github/workflows/ # CI/CD pipelines
├── blacklist # Default malware patterns
├── blacklist_permissive # Alternative blacklist
├── whitelist # Safe file patterns
└── CONTRIBUTING.md # Contribution guidelines
```
## Code Standards & Conventions
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
### Backend (C#)
- Follow [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
- Use nullable reference types (`<Nullable>enable</Nullable>`)
- Add XML documentation comments for public APIs
- Write unit tests for business logic
- Use meaningful names - avoid abbreviations unless widely understood
- Keep services focused - single responsibility principle
- **Study existing service implementations before creating new ones**
### Frontend (TypeScript/Angular)
- Follow [Angular Style Guide](https://angular.io/guide/styleguide)
- Use TypeScript strict mode
- All components must be **standalone** (no NgModules) with **ChangeDetectionStrategy.OnPush**
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
- Follow the 3-layer SCSS design system (`_variables``_tokens``_themes`) for styling
- Component naming: `{feature}.component.ts`
- Service naming: `{feature}.service.ts`
- **Look at similar existing components before creating new ones**
### Testing
- Write unit tests for new features and bug fixes
- Use descriptive test names that explain what is being tested
- Backend: xUnit or NUnit conventions
- Frontend: Jasmine/Karma
- **Test that existing functionality still works after changes**
### Git Commit Messages
- Use clear, descriptive messages in imperative mood
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
### Discovering Issues
If you encounter potential gotchas, common mistakes, or areas that need special attention during development:
- **Flag them to the maintainer immediately**
- Document them if confirmed
- Consider if they should be added to this guide
## Database Migrations
Cleanuparr uses two separate database contexts:
- **DataContext**: Main application data
- **EventsContext**: Event logging and audit trail
### Creating Migrations
From the `code` directory:
```bash
# Data migrations
make migrate-data name=YourMigrationName
# Events migrations
make migrate-events name=YourMigrationName
```
Example:
```bash
make migrate-data name=AddDownloadClientConfig
make migrate-events name=AddStrikeEvents
```
## Common Development Workflows
### Adding a New *arr Application Integration
1. Add integration in `Cleanuparr.Infrastructure/Arr/`
2. Update domain models in `Cleanuparr.Domain/`
3. Create/update services in `Cleanuparr.Application/`
4. Add API endpoints in `Cleanuparr.Api/`
5. Update frontend in `code/frontend/src/app/`
6. Document in `docs/docs/`
### Adding a New Download Client
1. Add client implementation in `Cleanuparr.Infrastructure/DownloadClients/`
2. Follow existing patterns (qBittorrent, Transmission, etc.)
3. Add configuration models to `Cleanuparr.Domain/`
4. Update API and frontend as above
### Adding a New Notification Provider
1. Add provider in `Cleanuparr.Infrastructure/Notifications/`
2. Update configuration models
3. Add UI configuration in frontend
4. Test with actual service
## Important Files
### Configuration Files
- `code/backend/Cleanuparr.Api/appsettings.json` - Backend configuration
- `code/frontend/angular.json` - Angular build configuration
- `code/Dockerfile` - Docker multi-stage build
- `docs/docusaurus.config.ts` - Documentation site config
### CI/CD Workflows
- `.github/workflows/test.yml` - Run tests
- `.github/workflows/build-docker.yml` - Build Docker images
- `.github/workflows/build-executable.yml` - Build standalone executables
- `.github/workflows/release.yml` - Create releases
- `.github/workflows/docs.yml` - Deploy documentation
### Malware Protection
- `blacklist` - Default malware file patterns (strict)
- `blacklist_permissive` - Less strict patterns
- `whitelist` - Known safe file extensions
- `whitelist_with_subtitles` - Includes subtitle formats
## Contributing Guidelines
### Before Starting Work
1. **Announce your intent** - Comment on an issue or create a new one
2. **Wait for approval** from maintainers
3. Fork the repository and create a feature branch
4. Make your changes following code standards
5. Test thoroughly (both manual and automated tests)
6. Submit a PR with clear description and testing notes
### Pull Request Requirements
- Link to related issue
- Clear description of changes
- Evidence of testing
- Updated documentation if needed
- No breaking changes without discussion
## Docker Development
### Build Local Docker Image
```bash
cd code
docker build \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
### Multi-Architecture Build
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
## Environment Variables
When running via Docker:
- `PORT` - API port (default: 11011)
- `PUID` - User ID for file permissions
- `PGID` - Group ID for file permissions
- `TZ` - Timezone (e.g., `America/New_York`)
## Security & Safety
- Never commit sensitive data (API keys, tokens, passwords)
- All *arr and download client credentials are stored encrypted
- The malware detection system uses pattern matching on file extensions and names
- Always validate user input on both frontend and backend
- Follow OWASP guidelines for web application security
## Additional Resources
- **Documentation**: https://cleanuparr.github.io/Cleanuparr/
- **Discord**: https://discord.gg/SCtMCgtsc4
- **GitHub Issues**: https://github.com/Cleanuparr/Cleanuparr/issues
- **Releases**: https://github.com/Cleanuparr/Cleanuparr/releases
## Working with Claude - IMPORTANT
### Core Principles
1. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
2. **Don't break existing functionality** - Everything is important and needs to work
3. **Follow existing coding style** - Study the codebase patterns before making changes
4. **Use current coding standards** - If you want to introduce something new, ask first
### When Modifying Code
- **ALWAYS read existing files before suggesting changes**
- Understand the current architecture and patterns
- Prefer editing existing files over creating new ones
- Follow the established conventions in the codebase exactly
- Test changes locally when possible
- **If you're unsure about an approach, ask before implementing**
### When Adding Features
- Review similar existing features first to understand patterns
- Maintain consistency with existing UI/UX patterns
- Update both backend and frontend together
- Add/update documentation
- Consider backwards compatibility
- **Ask about architectural decisions before implementing new patterns**
### When Fixing Bugs
- Understand the root cause before proposing a fix
- **Be careful not to break other functionality** - test related areas
- Add tests to prevent regression
- Update relevant documentation if behavior changes
- Consider if other parts of the codebase might have similar issues
- **Flag any potential gotchas or issues you discover**
## Notes
- The project uses **Clean Architecture** - respect layer boundaries
- Database migrations require both contexts - don't forget EventsContext
- Frontend uses a **custom glassmorphism design system** - don't introduce external UI frameworks (no PrimeNG, Material, etc.)
- All frontend components are **standalone** with **OnPush** change detection
- All downloads from *arr apps are processed through a **strike system**
- The malware blocker is a critical security feature - changes require careful testing
- Cross-seed integration allows keeping torrents that are actively seeding
- Real-time updates use **SignalR** - maintain websocket patterns when adding features

View File

@@ -19,7 +19,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
### Prerequisites
- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Node.js 18+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations

View File

@@ -28,20 +28,23 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
## Sponsored by GitAds
[![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=cleanuparr/cleanuparr@github)](https://gitads.dev/v1/ad-track?source=cleanuparr/cleanuparr@github)
## Screenshots
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
## 🎯 Supported Applications
### *Arr Applications
### *Arr Applications (latest version)
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr**
- **Whisparr v2**
### Download Clients
### Download Clients (latest version)
- **qBittorrent**
- **Transmission**
- **Deluge**
@@ -116,4 +119,4 @@ Special thanks for inspiration go to:
# Buy me a coffee
If I made your life just a tiny bit easier, consider buying me a coffee!
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>

View File

@@ -38,4 +38,50 @@ backend/**/Tests/
# Development files
docker-compose*.yml
test/
test/
# ================================
# Node and build output
# ================================
node_modules
dist
out-tsc
.angular
.cache
.tmp
# ================================
# Testing & Coverage
# ================================
coverage
jest
cypress
cypress/screenshots
cypress/videos
reports
playwright-report
.vite
.vitepress
# ================================
# Environment & log files
# ================================
*.env*
!*.env.production
*.log
*.tsbuildinfo
# ================================
# Docker & local orchestration
# ================================
Dockerfile
Dockerfile.*
.dockerignore
docker-compose.yml
docker-compose*.yml
# ================================
# Miscellaneous
# ================================
*.bak
*.old

View File

@@ -1,5 +1,5 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
@@ -15,7 +15,7 @@ COPY frontend/ .
RUN npm run build
# Build .NET backend
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
ARG VERSION=0.0.1
ARG PACKAGES_USERNAME
@@ -23,10 +23,6 @@ ARG PACKAGES_PAT
WORKDIR /app
EXPOSE 11011
# Copy solution and project files first for better layer caching
# COPY backend/*.sln ./backend/
# COPY backend/*/*.csproj ./backend/*/
# Copy source code
COPY backend/ ./backend/
@@ -46,15 +42,23 @@ RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
/p:DebugSymbols=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
FROM mcr.microsoft.com/dotnet/aspnet:10.0
# Install required packages for user management and timezone support
RUN apt-get update && apt-get install -y \
# Install required packages for user management, timezone support, and Python for Apprise CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
tzdata \
gosu \
python3 \
python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment and install Apprise CLI
ENV VIRTUAL_ENV=/opt/apprise-venv
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN pip install --no-cache-dir apprise==1.9.6
ENV PUID=1000 \
PGID=1000 \
UMASK=022 \

View File

@@ -13,4 +13,24 @@ migrate-events:
ifndef name
$(error name is required. Usage: make migrate-events name=YourMigrationName)
endif
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
docker-build:
ifndef tag
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
endif
ifndef version
$(error version is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
endif
ifndef user
$(error user is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
endif
ifndef pat
$(error pat is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
endif
DOCKER_BUILDKIT=1 docker build \
--build-arg VERSION=$(version) \
--build-arg PACKAGES_USERNAME=$(user) \
--build-arg PACKAGES_PAT=$(pat) \
-t cleanuparr:$(tag) \
.

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<AssemblyName>Cleanuparr</AssemblyName>
<Version Condition="'$(Version)' == ''">0.0.1</Version>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishReadyToRun>true</PublishReadyToRun>
@@ -24,14 +24,14 @@
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.5.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.1" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />

View File

@@ -29,12 +29,24 @@ public class EventsController : ControllerBase
[FromQuery] string? eventType = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] string? search = null)
[FromQuery] string? search = null,
[FromQuery] string? jobRunId = null)
{
// Validate pagination parameters
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 100;
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
if (page < 1)
{
page = 1;
}
if (pageSize < 1)
{
pageSize = 100;
}
if (pageSize > 1000)
{
pageSize = 1000; // Cap at 1000 for performance
}
var query = _context.Events.AsQueryable();
@@ -62,6 +74,12 @@ public class EventsController : ControllerBase
query = query.Where(e => e.Timestamp <= toDate.Value);
}
// Apply job run ID exact-match filter
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out var jobRunGuid))
{
query = query.Where(e => e.JobRunId == jobRunGuid);
}
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
@@ -69,7 +87,10 @@ public class EventsController : ControllerBase
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.TrackingId.ToString(), pattern)
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern) ||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
);
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;

View File

@@ -66,7 +66,9 @@ public class ManualEventsController : ControllerBase
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern)
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern)
);
}

View File

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

View File

@@ -0,0 +1,189 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StrikesController : ControllerBase
{
private readonly EventsContext _context;
public StrikesController(EventsContext context)
{
_context = context;
}
/// <summary>
/// Gets download items with their strikes (grouped), with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<DownloadItemStrikesDto>>> GetStrikes(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? search = null,
[FromQuery] string? type = null)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _context.DownloadItems
.Include(d => d.Strikes)
.Where(d => d.Strikes.Any());
// Filter by strike type: only show items that have strikes of this type
if (!string.IsNullOrWhiteSpace(type))
{
if (Enum.TryParse<StrikeType>(type, true, out var strikeType))
query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType));
}
// Apply search filter on title or download hash
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(d =>
EF.Functions.Like(d.Title, pattern) ||
EF.Functions.Like(d.DownloadId, pattern));
}
var totalCount = await query.CountAsync();
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
var items = await query
.OrderByDescending(d => d.Strikes.Max(s => s.CreatedAt))
.Skip(skip)
.Take(pageSize)
.ToListAsync();
var dtos = items.Select(d => new DownloadItemStrikesDto
{
DownloadItemId = d.Id,
DownloadId = d.DownloadId,
Title = d.Title,
TotalStrikes = d.Strikes.Count,
StrikesByType = d.Strikes
.GroupBy(s => s.Type)
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
LatestStrikeAt = d.Strikes.Max(s => s.CreatedAt),
FirstStrikeAt = d.Strikes.Min(s => s.CreatedAt),
IsMarkedForRemoval = d.IsMarkedForRemoval,
IsRemoved = d.IsRemoved,
IsReturning = d.IsReturning,
Strikes = d.Strikes
.OrderByDescending(s => s.CreatedAt)
.Select(s => new StrikeDetailDto
{
Id = s.Id,
Type = s.Type.ToString(),
CreatedAt = s.CreatedAt,
LastDownloadedBytes = s.LastDownloadedBytes,
JobRunId = s.JobRunId,
}).ToList(),
}).ToList();
return Ok(new PaginatedResult<DownloadItemStrikesDto>
{
Items = dtos,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages,
});
}
/// <summary>
/// Gets the most recent individual strikes with download item info (for dashboard)
/// </summary>
[HttpGet("recent")]
public async Task<ActionResult<List<RecentStrikeDto>>> GetRecentStrikes(
[FromQuery] int count = 5)
{
if (count < 1) count = 1;
if (count > 50) count = 50;
var strikes = await _context.Strikes
.Include(s => s.DownloadItem)
.OrderByDescending(s => s.CreatedAt)
.Take(count)
.Select(s => new RecentStrikeDto
{
Id = s.Id,
Type = s.Type.ToString(),
CreatedAt = s.CreatedAt,
DownloadId = s.DownloadItem.DownloadId,
Title = s.DownloadItem.Title,
})
.ToListAsync();
return Ok(strikes);
}
/// <summary>
/// Gets all available strike types
/// </summary>
[HttpGet("types")]
public ActionResult<List<string>> GetStrikeTypes()
{
var types = Enum.GetNames(typeof(StrikeType)).ToList();
return Ok(types);
}
/// <summary>
/// Deletes all strikes for a specific download item
/// </summary>
[HttpDelete("{downloadItemId:guid}")]
public async Task<IActionResult> DeleteStrikesForItem(Guid downloadItemId)
{
var item = await _context.DownloadItems
.Include(d => d.Strikes)
.FirstOrDefaultAsync(d => d.Id == downloadItemId);
if (item == null)
return NotFound();
_context.Strikes.RemoveRange(item.Strikes);
_context.DownloadItems.Remove(item);
await _context.SaveChangesAsync();
return NoContent();
}
}
public class DownloadItemStrikesDto
{
public Guid DownloadItemId { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int TotalStrikes { get; set; }
public Dictionary<string, int> StrikesByType { get; set; } = new();
public DateTime LatestStrikeAt { get; set; }
public DateTime FirstStrikeAt { get; set; }
public bool IsMarkedForRemoval { get; set; }
public bool IsRemoved { get; set; }
public bool IsReturning { get; set; }
public List<StrikeDetailDto> Strikes { get; set; } = [];
}
public class StrikeDetailDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public long? LastDownloadedBytes { get; set; }
public Guid JobRunId { get; set; }
}
public class RecentStrikeDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
}

View File

@@ -1,7 +1,11 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
namespace Cleanuparr.Api.DependencyInjection;
@@ -11,7 +15,13 @@ public static class NotificationsDI
services
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<IAppriseCliProxy, AppriseCliProxy>()
.AddSingleton<IAppriseCliDetector, AppriseCliDetector>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<ITelegramProxy, TelegramProxy>()
.AddScoped<IDiscordProxy, DiscordProxy>()
.AddScoped<IGotifyProxy, GotifyProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

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

View File

@@ -18,13 +18,20 @@ public sealed record ArrInstanceRequest
[Required]
public required string ApiKey { get; init; }
[Required]
public required float Version { get; init; }
public string? ExternalUrl { get; init; }
public ArrInstance ToEntity(Guid configId) => new()
{
Enabled = Enabled,
Name = Name,
Url = new Uri(Url),
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
ApiKey = ApiKey,
ArrConfigId = configId,
Version = Version,
};
public void ApplyTo(ArrInstance instance)
@@ -32,6 +39,8 @@ public sealed record ArrInstanceRequest
instance.Enabled = Enabled;
instance.Name = Name;
instance.Url = new Uri(Url);
instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null;
instance.ApiKey = ApiKey;
instance.Version = Version;
}
}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ using System;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;

View File

@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public record SeedingRuleRequest
{
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; init; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; init; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; init; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public bool DeleteSourceFiles { get; init; } = true;
}

View File

@@ -1,8 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public record UpdateDownloadCleanerConfigRequest
public sealed record UpdateDownloadCleanerConfigRequest
{
public bool Enabled { get; init; }
@@ -13,7 +11,7 @@ public record UpdateDownloadCleanerConfigRequest
/// </summary>
public bool UseAdvancedScheduling { get; init; }
public List<CleanCategoryRequest> Categories { get; init; } = [];
public List<SeedingRuleRequest> Categories { get; init; } = [];
public bool DeletePrivate { get; init; }
@@ -26,30 +24,9 @@ public record UpdateDownloadCleanerConfigRequest
public bool UnlinkedUseTag { get; init; }
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
public List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
public List<string> UnlinkedCategories { get; init; } = [];
public List<string> IgnoredDownloads { get; init; } = [];
}
public record CleanCategoryRequest
{
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; init; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; init; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; init; } = -1;
}

View File

@@ -3,7 +3,7 @@ using System.IO;
using System.Linq;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
@@ -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
});
}

View File

@@ -16,7 +16,7 @@ public sealed record CreateDownloadClientRequest
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Host { get; init; }
public string? Username { get; init; }
@@ -24,6 +24,8 @@ public sealed record CreateDownloadClientRequest
public string? UrlBase { get; init; }
public string? ExternalUrl { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -31,10 +33,20 @@ public sealed record CreateDownloadClientRequest
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("External URL is not a valid URL");
}
}
public DownloadClientConfig ToEntity() => new()
@@ -43,9 +55,10 @@ public sealed record CreateDownloadClientRequest
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
};
}

View File

@@ -0,0 +1,48 @@
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 string? Host { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public string? UrlBase { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
}
public DownloadClientConfig ToTestConfig() => new()
{
Id = Guid.NewGuid(),
Enabled = true,
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
}

View File

@@ -16,7 +16,7 @@ public sealed record UpdateDownloadClientRequest
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Host { get; init; }
public string? Username { get; init; }
@@ -24,6 +24,8 @@ public sealed record UpdateDownloadClientRequest
public string? UrlBase { get; init; }
public string? ExternalUrl { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -31,10 +33,20 @@ public sealed record UpdateDownloadClientRequest
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("External URL is not a valid URL");
}
}
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
@@ -43,9 +55,10 @@ public sealed record UpdateDownloadClientRequest
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
};
}

View File

@@ -2,12 +2,11 @@ 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;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
@@ -18,15 +17,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 +148,33 @@ public sealed class DownloadClientController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPost("download_client/test")]
public async Task<IActionResult> TestDownloadClient([FromBody] TestDownloadClientRequest request)
{
try
{
request.Validate();
var testConfig = request.ToTestConfig();
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
var healthResult = await downloadService.HealthCheckAsync();
if (healthResult.IsHealthy)
{
return Ok(new
{
Message = $"Connection to {request.TypeName} successful",
ResponseTime = healthResult.ResponseTime.TotalMilliseconds
});
}
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
}
}
}

View File

@@ -24,10 +24,14 @@ public sealed record UpdateGeneralConfigRequest
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
public bool StatusCheckEnabled { get; init; } = true;
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; init; } = [];
public ushort StrikeInactivityWindowHours { get; init; } = 24;
public UpdateLoggingConfigRequest Log { get; init; } = new();
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
@@ -39,8 +43,10 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
existingConfig.SearchEnabled = SearchEnabled;
existingConfig.SearchDelay = SearchDelay;
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
@@ -58,6 +64,16 @@ public sealed record UpdateGeneralConfigRequest
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
if (config.StrikeInactivityWindowHours is 0)
{
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be greater than 0");
}
if (config.StrikeInactivityWindowHours > 168)
{
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be less than or equal to 168");
}
config.Log.Validate();
}

View File

@@ -78,6 +78,21 @@ public sealed class GeneralConfigController : ControllerBase
}
}
[HttpPost("strikes/purge")]
public async Task<IActionResult> PurgeAllStrikes(
[FromServices] EventsContext eventsContext)
{
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
var deletedItems = await eventsContext.DownloadItems
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
_logger.LogWarning("Purged all strikes: {strikes} strikes, {items} download items removed",
deletedStrikes, deletedItems);
return Ok(new { DeletedStrikes = deletedStrikes, DeletedItems = deletedItems });
}
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
{
if (!wasDryRun || isDryRun)

View File

@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;

View File

@@ -1,10 +1,18 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateDiscordProviderRequest : CreateNotificationProviderRequestBase
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateGotifyProviderRequest : CreateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreatePushoverProviderRequest : CreateNotificationProviderRequestBase
{
public string ApiToken { get; init; } = string.Empty;
public string UserKey { get; init; } = string.Empty;
public List<string> Devices { get; init; } = [];
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
public string? Sound { get; init; }
public int? Retry { get; init; }
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public sealed record CreateTelegramProviderRequest : CreateNotificationProviderRequestBase
{
public string BotToken { get; init; } = string.Empty;
public string ChatId { get; init; } = string.Empty;
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
}

View File

@@ -1,10 +1,18 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestAppriseProviderRequest
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestDiscordProviderRequest
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestGotifyProviderRequest
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestPushoverProviderRequest
{
public string ApiToken { get; init; } = string.Empty;
public string UserKey { get; init; } = string.Empty;
public List<string> Devices { get; init; } = [];
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
public string? Sound { get; init; }
public int? Retry { get; init; }
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public sealed record TestTelegramProviderRequest
{
public string BotToken { get; init; } = string.Empty;
public string ChatId { get; init; } = string.Empty;
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
}

View File

@@ -1,10 +1,18 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateDiscordProviderRequest : UpdateNotificationProviderRequestBase
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateGotifyProviderRequest : UpdateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdatePushoverProviderRequest : UpdateNotificationProviderRequestBase
{
public string ApiToken { get; init; } = string.Empty;
public string UserKey { get; init; } = string.Empty;
public List<string> Devices { get; init; } = [];
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
public string? Sound { get; init; }
public int? Retry { get; init; }
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public sealed record UpdateTelegramProviderRequest : CreateNotificationProviderRequestBase
{
public string BotToken { get; init; } = string.Empty;
public string ChatId { get; init; } = string.Empty;
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
}

View File

@@ -1,15 +1,18 @@
using System.Net;
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.Notifications.Controllers;
@@ -21,17 +24,20 @@ public sealed class NotificationProvidersController : ControllerBase
private readonly DataContext _dataContext;
private readonly INotificationConfigurationService _notificationConfigurationService;
private readonly NotificationService _notificationService;
private readonly IAppriseCliDetector _appriseCliDetector;
public NotificationProvidersController(
ILogger<NotificationProvidersController> logger,
DataContext dataContext,
INotificationConfigurationService notificationConfigurationService,
NotificationService notificationService)
NotificationService notificationService,
IAppriseCliDetector appriseCliDetector)
{
_logger = logger;
_dataContext = dataContext;
_notificationConfigurationService = notificationConfigurationService;
_notificationService = notificationService;
_appriseCliDetector = appriseCliDetector;
}
[HttpGet]
@@ -44,6 +50,10 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -68,6 +78,10 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => p.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => p.DiscordConfiguration ?? new object(),
NotificationProviderType.Gotify => p.GotifyConfiguration ?? new object(),
_ => new object()
}
})
@@ -84,6 +98,18 @@ public sealed class NotificationProvidersController : ControllerBase
}
}
[HttpGet("apprise/cli-status")]
public async Task<IActionResult> GetAppriseCliStatus()
{
string? version = await _appriseCliDetector.GetAppriseVersionAsync();
return Ok(new
{
Available = version is not null,
Version = version
});
}
[HttpPost("notifiarr")]
public async Task<IActionResult> CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderRequest newProvider)
{
@@ -160,9 +186,11 @@ public sealed class NotificationProvidersController : ControllerBase
var appriseConfig = new AppriseConfig
{
Mode = newProvider.Mode,
Url = newProvider.Url,
Key = newProvider.Key,
Tags = newProvider.Tags
Tags = newProvider.Tags,
ServiceUrls = newProvider.ServiceUrls
};
appriseConfig.Validate();
@@ -270,6 +298,69 @@ public sealed class NotificationProvidersController : ControllerBase
}
}
[HttpPost("telegram")]
public async Task<IActionResult> CreateTelegramProvider([FromBody] CreateTelegramProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var telegramConfig = new TelegramConfig
{
BotToken = newProvider.BotToken,
ChatId = newProvider.ChatId,
TopicId = newProvider.TopicId,
SendSilently = newProvider.SendSilently
};
telegramConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Telegram,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
TelegramConfiguration = telegramConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Telegram provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("notifiarr/{id:guid}")]
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderRequest updatedProvider)
{
@@ -380,9 +471,11 @@ public sealed class NotificationProvidersController : ControllerBase
var appriseConfig = new AppriseConfig
{
Mode = updatedProvider.Mode,
Url = updatedProvider.Url,
Key = updatedProvider.Key,
Tags = updatedProvider.Tags
Tags = updatedProvider.Tags,
ServiceUrls = updatedProvider.ServiceUrls
};
if (existingProvider.AppriseConfiguration != null)
@@ -514,6 +607,87 @@ public sealed class NotificationProvidersController : ControllerBase
}
}
[HttpPut("telegram/{id:guid}")]
public async Task<IActionResult> UpdateTelegramProvider(Guid id, [FromBody] UpdateTelegramProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.TelegramConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Telegram);
if (existingProvider == null)
{
return NotFound($"Telegram provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var telegramConfig = new TelegramConfig
{
BotToken = updatedProvider.BotToken,
ChatId = updatedProvider.ChatId,
TopicId = updatedProvider.TopicId,
SendSilently = updatedProvider.SendSilently
};
if (existingProvider.TelegramConfiguration != null)
{
telegramConfig = telegramConfig with { Id = existingProvider.TelegramConfiguration.Id };
}
telegramConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
TelegramConfiguration = telegramConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Telegram provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
{
@@ -524,6 +698,10 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
@@ -583,12 +761,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -599,9 +777,11 @@ public sealed class NotificationProvidersController : ControllerBase
{
var appriseConfig = new AppriseConfig
{
Mode = testRequest.Mode,
Url = testRequest.Url,
Key = testRequest.Key,
Tags = testRequest.Tags
Tags = testRequest.Tags,
ServiceUrls = testRequest.ServiceUrls
};
appriseConfig.Validate();
@@ -624,12 +804,16 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (AppriseException exception)
{
return StatusCode((int)HttpStatusCode.InternalServerError, exception.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -670,12 +854,59 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Ntfy provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
[HttpPost("telegram/test")]
public async Task<IActionResult> TestTelegramProvider([FromBody] TestTelegramProviderRequest testRequest)
{
try
{
var telegramConfig = new TelegramConfig
{
BotToken = testRequest.BotToken,
ChatId = testRequest.ChatId,
TopicId = testRequest.TopicId,
SendSilently = testRequest.SendSilently
};
telegramConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Telegram,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = telegramConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (TelegramException ex)
{
_logger.LogWarning(ex, "Failed to test Telegram provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Telegram provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -701,8 +932,586 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => provider.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => provider.DiscordConfiguration ?? new object(),
NotificationProviderType.Gotify => provider.GotifyConfiguration ?? new object(),
_ => new object()
}
};
}
[HttpPost("discord")]
public async Task<IActionResult> CreateDiscordProvider([FromBody] CreateDiscordProviderRequest 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 discordConfig = new DiscordConfig
{
WebhookUrl = newProvider.WebhookUrl,
Username = newProvider.Username,
AvatarUrl = newProvider.AvatarUrl
};
discordConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Discord,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
DiscordConfiguration = discordConfig
};
_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 Discord provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("discord/{id:guid}")]
public async Task<IActionResult> UpdateDiscordProvider(Guid id, [FromBody] UpdateDiscordProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.DiscordConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Discord);
if (existingProvider == null)
{
return NotFound($"Discord 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 discordConfig = new DiscordConfig
{
WebhookUrl = updatedProvider.WebhookUrl,
Username = updatedProvider.Username,
AvatarUrl = updatedProvider.AvatarUrl
};
if (existingProvider.DiscordConfiguration != null)
{
discordConfig = discordConfig with { Id = existingProvider.DiscordConfiguration.Id };
}
discordConfig.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,
DiscordConfiguration = discordConfig,
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 Discord provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("discord/test")]
public async Task<IActionResult> TestDiscordProvider([FromBody] TestDiscordProviderRequest testRequest)
{
try
{
var discordConfig = new DiscordConfig
{
WebhookUrl = testRequest.WebhookUrl,
Username = testRequest.Username,
AvatarUrl = testRequest.AvatarUrl
};
discordConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Discord,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = discordConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (DiscordException ex)
{
_logger.LogWarning(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
[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}" });
}
}
[HttpPost("gotify")]
public async Task<IActionResult> CreateGotifyProvider([FromBody] CreateGotifyProviderRequest 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 gotifyConfig = new GotifyConfig
{
ServerUrl = newProvider.ServerUrl,
ApplicationToken = newProvider.ApplicationToken,
Priority = newProvider.Priority
};
gotifyConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Gotify,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
GotifyConfiguration = gotifyConfig
};
_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 Gotify provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("gotify/{id:guid}")]
public async Task<IActionResult> UpdateGotifyProvider(Guid id, [FromBody] UpdateGotifyProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.GotifyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Gotify);
if (existingProvider == null)
{
return NotFound($"Gotify 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 gotifyConfig = new GotifyConfig
{
ServerUrl = updatedProvider.ServerUrl,
ApplicationToken = updatedProvider.ApplicationToken,
Priority = updatedProvider.Priority
};
if (existingProvider.GotifyConfiguration != null)
{
gotifyConfig = gotifyConfig with { Id = existingProvider.GotifyConfiguration.Id };
}
gotifyConfig.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,
GotifyConfiguration = gotifyConfig,
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 Gotify provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("gotify/test")]
public async Task<IActionResult> TestGotifyProvider([FromBody] TestGotifyProviderRequest testRequest)
{
try
{
var gotifyConfig = new GotifyConfig
{
ServerUrl = testRequest.ServerUrl,
ApplicationToken = testRequest.ApplicationToken,
Priority = testRequest.Priority
};
gotifyConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Gotify,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = gotifyConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (GotifyException ex)
{
_logger.LogWarning(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
}

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;

View File

@@ -1,7 +1,12 @@
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.SignalR;
using Quartz;
using Serilog.Context;
@@ -14,48 +19,73 @@ public sealed class GenericJob<T> : IJob
{
private readonly ILogger<GenericJob<T>> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
public async Task Execute(IJobExecutionContext context)
{
using var _ = LogContext.PushProperty("JobName", typeof(T).Name);
Guid jobRunId = Guid.CreateVersion7();
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobRunStatus? status = null;
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
await BroadcastJobStatus(hubContext, jobManagementService, false);
var jobRun = new JobRun { Id = jobRunId, Type = jobType };
eventsContext.JobRuns.Add(jobRun);
await eventsContext.SaveChangesAsync();
ContextProvider.SetJobRunId(jobRunId);
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
await BroadcastJobStatus(hubContext, jobManagementService, true);
status = JobRunStatus.Completed;
await BroadcastJobStatus(hubContext, jobManagementService, jobType, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "{name} failed", typeof(T).Name);
status = JobRunStatus.Failed;
}
finally
{
await using var finalScope = _scopeFactory.CreateAsyncScope();
var eventsContext = finalScope.ServiceProvider.GetRequiredService<EventsContext>();
var jobRun = await eventsContext.JobRuns.FindAsync(jobRunId);
if (jobRun is not null)
{
jobRun.CompletedAt = DateTime.UtcNow;
jobRun.Status = status;
await eventsContext.SaveChangesAsync();
}
}
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, bool isFinished)
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try
{
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobInfo jobInfo = await jobManagementService.GetJob(jobType);
if (isFinished)
{
jobInfo.Status = "Scheduled";
}
await hubContext.Clients.All.SendAsync("JobStatusUpdate", jobInfo);
}
catch (Exception ex)

View File

@@ -1,23 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
namespace Cleanuparr.Api.Models;
/// <summary>
/// Legacy namespace shim; prefer <see cref="UpdateDownloadCleanerConfigRequest"/> from
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
/// </summary>
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.UpdateDownloadCleanerConfigRequest instead")]
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
public record UpdateDownloadCleanerConfigDto : UpdateDownloadCleanerConfigRequest;
/// <summary>
/// Legacy namespace shim; prefer <see cref="CleanCategoryRequest"/> from
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
/// </summary>
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.CleanCategoryRequest instead")]
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
public record CleanCategoryDto : CleanCategoryRequest;

View File

@@ -1,3 +1,4 @@
using System.Net;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Cleanuparr.Api;
@@ -33,12 +34,24 @@ builder.Configuration
int.TryParse(builder.Configuration.GetValue<string>("PORT"), out int port);
port = port is 0 ? 11011 : port;
string? bindAddress = builder.Configuration.GetValue<string>("BIND_ADDRESS");
if (!builder.Environment.IsDevelopment())
{
// If no port is configured, default to 11011
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(port);
if (string.IsNullOrEmpty(bindAddress) || bindAddress is "0.0.0.0" || bindAddress is "*")
{
options.ListenAnyIP(port);
}
else if (IPAddress.TryParse(bindAddress, out var ipAddress))
{
options.Listen(ipAddress, port);
}
else
{
throw new ArgumentException($"Invalid BIND_ADDRESS: '{bindAddress}'");
}
});
}
@@ -124,7 +137,7 @@ if (basePath is not null)
}
}
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
logger.LogInformation("Server configuration: BIND_ADDRESS={bindAddress}, PORT={port}, BASE_PATH={basePath}", bindAddress ?? "0.0.0.0", port, basePath ?? "/");
// Initialize the host
app.Init();

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

View File

@@ -2,17 +2,17 @@ namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueRecord
{
// Sonarr and Whisparr
// Sonarr and Whisparr v2
public long SeriesId { get; init; }
public long EpisodeId { get; init; }
public long SeasonNumber { get; init; }
public QueueSeries? Series { get; init; }
// Radarr
// Radarr and Whisparr v3
public long MovieId { get; init; }
public QueueSeries? Movie { get; init; }
public QueueMovie? Movie { get; init; }
// Lidarr
public long ArtistId { get; init; }

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Entities.HealthCheck;
public sealed record HealthCheckResult
{
public bool IsHealthy { get; set; }
public string? ErrorMessage { get; set; }
public TimeSpan ResponseTime { get; set; }
}

View File

@@ -4,49 +4,34 @@ namespace Cleanuparr.Domain.Entities;
/// Universal abstraction for a torrent item across all download clients.
/// Provides a unified interface for accessing torrent properties and state.
/// </summary>
public interface ITorrentItem
public interface ITorrentItemWrapper
{
// Basic identification
string Hash { get; }
string Name { get; }
// Privacy and tracking
bool IsPrivate { get; }
IReadOnlyList<string> Trackers { get; }
// Size and progress
long Size { get; }
double CompletionPercentage { get; }
long DownloadedBytes { get; }
long TotalUploaded { get; }
// Speed and transfer rates
long DownloadSpeed { get; }
long UploadSpeed { get; }
double Ratio { get; }
// Time tracking
long Eta { get; }
DateTime? DateAdded { get; }
DateTime? DateCompleted { get; }
long SeedingTimeSeconds { get; }
// Categories and tags
string? Category { get; }
IReadOnlyList<string> Tags { get; }
string? Category { get; set; }
// State checking methods
bool IsDownloading();
bool IsStalled();
bool IsSeeding();
bool IsCompleted();
bool IsPaused();
bool IsQueued();
bool IsChecking();
bool IsAllocating();
bool IsMetadataDownloading();
// Filtering methods
/// <summary>
/// Determines if this torrent should be ignored based on the provided patterns.
/// Checks if any pattern matches the torrent name, hash, or tracker.

View File

@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Domain.Entities.Whisparr;
public sealed record WhisparrCommand
public sealed record WhisparrV2Command
{
public string Name { get; set; }

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Whisparr;
public sealed record WhisparrV3Command
{
public required string Name { get; init; }
public required List<long> MovieIds { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace Cleanuparr.Domain.Enums;
public enum AppriseMode
{
Api,
Cli
}

View File

@@ -0,0 +1,7 @@
namespace Cleanuparr.Domain.Enums;
public enum JobRunStatus
{
Completed,
Failed
}

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Domain.Enums;
public enum JobType
{
QueueCleaner,
MalwareBlocker,
DownloadCleaner,
BlacklistSynchronizer,
}

View File

@@ -4,5 +4,9 @@ public enum NotificationProviderType
{
Notifiarr,
Apprise,
Ntfy
Ntfy,
Pushover,
Telegram,
Discord,
Gotify,
}

View File

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

View File

@@ -0,0 +1,36 @@
namespace Cleanuparr.Domain.Enums;
public static class PushoverSounds
{
public const string Pushover = "pushover";
public const string Bike = "bike";
public const string Bugle = "bugle";
public const string Cashregister = "cashregister";
public const string Classical = "classical";
public const string Cosmic = "cosmic";
public const string Falling = "falling";
public const string Gamelan = "gamelan";
public const string Incoming = "incoming";
public const string Intermission = "intermission";
public const string Magic = "magic";
public const string Mechanical = "mechanical";
public const string Pianobar = "pianobar";
public const string Siren = "siren";
public const string Spacealarm = "spacealarm";
public const string Tugboat = "tugboat";
public const string Alien = "alien";
public const string Climb = "climb";
public const string Persistent = "persistent";
public const string Echo = "echo";
public const string Updown = "updown";
public const string Vibrate = "vibrate";
public const string None = "none";
public static readonly string[] All =
[
Pushover, Bike, Bugle, Cashregister, Classical, Cosmic, Falling,
Gamelan, Incoming, Intermission, Magic, Mechanical, Pianobar,
Siren, Spacealarm, Tugboat, Alien, Climb, Persistent, Echo,
Updown, Vibrate, None
];
}

View File

@@ -1,11 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
</ItemGroup>
@@ -15,8 +19,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
@@ -26,6 +31,10 @@
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,130 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Events;
/// <summary>
/// Integration tests for the cleanup logic that actually deletes events
/// </summary>
public class EventCleanupServiceIntegrationTests : IDisposable
{
private readonly EventsContext _context;
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
private readonly IServiceProvider _serviceProvider;
private readonly string _dbName;
public EventCleanupServiceIntegrationTests()
{
_dbName = Guid.NewGuid().ToString();
var services = new ServiceCollection();
// Setup in-memory database
services.AddDbContext<EventsContext>(options =>
options.UseInMemoryDatabase(databaseName: _dbName));
_serviceProvider = services.BuildServiceProvider();
_loggerMock = new Mock<ILogger<EventCleanupService>>();
using var scope = _serviceProvider.CreateScope();
_context = scope.ServiceProvider.GetRequiredService<EventsContext>();
}
public void Dispose()
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
context.Database.EnsureDeleted();
}
[Fact]
public async Task CleanupService_PreservesRecentEvents()
{
// Arrange - Add recent events (within retention period)
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
context.Events.Add(new AppEvent
{
Id = Guid.NewGuid(),
EventType = EventType.QueueItemDeleted,
Message = "Recent event 1",
Severity = EventSeverity.Information,
Timestamp = DateTime.UtcNow.AddDays(-5)
});
context.Events.Add(new AppEvent
{
Id = Guid.NewGuid(),
EventType = EventType.DownloadCleaned,
Message = "Recent event 2",
Severity = EventSeverity.Important,
Timestamp = DateTime.UtcNow.AddDays(-10)
});
await context.SaveChangesAsync();
}
// Verify events exist
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
var count = await context.Events.CountAsync();
Assert.Equal(2, count);
}
}
[Fact]
public async Task EventCleanupService_CanStartAndStop()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act
cts.CancelAfter(100);
await service.StartAsync(cts.Token);
// Give some time for the service to process
await Task.Delay(150);
await service.StopAsync(CancellationToken.None);
// Assert - the service should complete without throwing
Assert.True(true);
}
[Fact]
public async Task EventCleanupService_HandlesExceptionsGracefully()
{
// Arrange
// Note: In-memory provider doesn't support ExecuteDeleteAsync,
// so the cleanup will fail. This test verifies the service handles errors gracefully.
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act
cts.CancelAfter(100);
await service.StartAsync(cts.Token);
await Task.Delay(150);
await service.StopAsync(CancellationToken.None);
// Assert - the service should handle the error and continue (log it but not crash)
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Failed to perform event cleanup")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
}

View File

@@ -0,0 +1,130 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Events;
public class EventCleanupServiceTests : IDisposable
{
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
private readonly ServiceCollection _services;
private readonly IServiceProvider _serviceProvider;
private readonly string _dbName;
public EventCleanupServiceTests()
{
_loggerMock = new Mock<ILogger<EventCleanupService>>();
_services = new ServiceCollection();
_dbName = Guid.NewGuid().ToString();
// Setup in-memory database for testing
_services.AddDbContext<EventsContext>(options =>
options.UseInMemoryDatabase(databaseName: _dbName));
_serviceProvider = _services.BuildServiceProvider();
}
public void Dispose()
{
// Cleanup the in-memory database
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
context.Database.EnsureDeleted();
}
[Fact]
public async Task ExecuteAsync_LogsStartMessage()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act - start and immediately cancel
cts.CancelAfter(100);
await service.StartAsync(cts.Token);
await Task.Delay(200); // Give it time to process
await service.StopAsync(CancellationToken.None);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("started")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task StopAsync_LogsStopMessage()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act
cts.CancelAfter(50);
await service.StartAsync(cts.Token);
await Task.Delay(100);
await service.StopAsync(CancellationToken.None);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopping")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public void Constructor_InitializesWithCorrectParameters()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
// Act
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
// Assert - service should be created without exception
Assert.NotNull(service);
}
[Fact]
public async Task ExecuteAsync_GracefullyHandlesCancellation()
{
// Arrange
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
var cts = new CancellationTokenSource();
// Act - cancel immediately
cts.Cancel();
// Start should not throw
await service.StartAsync(cts.Token);
await Task.Delay(50);
await service.StopAsync(CancellationToken.None);
// Assert - should have logged stopped message
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("stopped")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}

View File

@@ -0,0 +1,526 @@
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);
// Setup JobRunId in context for tests
ContextProvider.SetJobRunId(Guid.NewGuid());
}
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(ContextProvider.Keys.ItemName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.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(ContextProvider.Keys.ItemName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.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(ContextProvider.Keys.ItemName, "Cleaned Download");
ContextProvider.Set(ContextProvider.Keys.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(ContextProvider.Keys.ItemName, "Test");
ContextProvider.Set(ContextProvider.Keys.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(ContextProvider.Keys.ArrInstanceUrl, 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(ContextProvider.Keys.ArrInstanceUrl, 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(ContextProvider.Keys.ItemName, "Category Test");
ContextProvider.Set(ContextProvider.Keys.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(ContextProvider.Keys.ItemName, "Tag Test");
ContextProvider.Set(ContextProvider.Keys.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(ContextProvider.Keys.ItemName, "Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
// Act
await _publisher.PublishCategoryChanged("old", "new", isTag: true);
// Assert
_notificationPublisherMock.Verify(n => n.NotifyCategoryChanged("old", "new", true), Times.Once);
}
#endregion
}

View File

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

View File

@@ -0,0 +1,178 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
public class WhisparrV2ClientTests
{
private readonly Mock<ILogger<WhisparrV2Client>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<IStriker> _strikerMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly WhisparrV2Client _client;
public WhisparrV2ClientTests()
{
_loggerMock = new Mock<ILogger<WhisparrV2Client>>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_strikerMock = new Mock<IStriker>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
_client = new WhisparrV2Client(
_loggerMock.Object,
_httpClientFactoryMock.Object,
_strikerMock.Object,
_dryRunInterceptorMock.Object
);
}
#region IsRecordValid Tests
[Fact]
public void IsRecordValid_WhenEpisodeIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 0,
SeriesId = 1
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("episode id and/or series id missing")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public void IsRecordValid_WhenSeriesIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 1,
SeriesId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenBothIdsAreZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 0,
SeriesId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenBothIdsAreSet_ReturnsTrue()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "abc123",
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.True(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = null!,
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Episode",
DownloadId = "",
Protocol = "torrent",
EpisodeId = 42,
SeriesId = 10
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
#endregion
}

View File

@@ -0,0 +1,132 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
public class WhisparrV3ClientTests
{
private readonly Mock<ILogger<WhisparrV3Client>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<IStriker> _strikerMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly WhisparrV3Client _client;
public WhisparrV3ClientTests()
{
_loggerMock = new Mock<ILogger<WhisparrV3Client>>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_strikerMock = new Mock<IStriker>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
_client = new WhisparrV3Client(
_loggerMock.Object,
_httpClientFactoryMock.Object,
_strikerMock.Object,
_dryRunInterceptorMock.Object
);
}
#region IsRecordValid Tests
[Fact]
public void IsRecordValid_WhenMovieIdIsZero_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "abc123",
Protocol = "torrent",
MovieId = 0
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("movie id missing")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
Times.Once
);
}
[Fact]
public void IsRecordValid_WhenMovieIdIsSet_ReturnsTrue()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "abc123",
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.True(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = null!,
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
[Fact]
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
{
// Arrange
var record = new QueueRecord
{
Id = 1,
Title = "Test Movie",
DownloadId = "",
Protocol = "torrent",
MovieId = 42
};
// Act
var result = _client.IsRecordValid(record);
// Assert
Assert.False(result);
}
#endregion
}

View File

@@ -0,0 +1,365 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Persistence.Models.State;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using System.Net;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.BlacklistSync;
public class BlacklistSynchronizerTests : IDisposable
{
private readonly Mock<ILogger<BlacklistSynchronizer>> _loggerMock;
private readonly DataContext _dataContext;
private readonly Mock<IDownloadServiceFactory> _downloadServiceFactoryMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly FileReader _fileReader;
private readonly BlacklistSynchronizer _synchronizer;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly SqliteConnection _connection;
public BlacklistSynchronizerTests()
{
_loggerMock = new Mock<ILogger<BlacklistSynchronizer>>();
// Use SQLite in-memory with shared connection to support complex types
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(_connection)
.Options;
_dataContext = new DataContext(options);
_dataContext.Database.EnsureCreated();
_downloadServiceFactoryMock = new Mock<IDownloadServiceFactory>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
// Setup interceptor to execute the action with params using DynamicInvoke
_dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
var result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
});
// Setup mock HTTP handler for FileReader
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
var httpClientFactoryMock = new Mock<IHttpClientFactory>();
httpClientFactoryMock
.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(httpClient);
_fileReader = new FileReader(httpClientFactoryMock.Object);
_synchronizer = new BlacklistSynchronizer(
_loggerMock.Object,
_dataContext,
_downloadServiceFactoryMock.Object,
_fileReader,
_dryRunInterceptorMock.Object
);
}
public void Dispose()
{
_dataContext.Dispose();
_connection.Dispose();
}
#region ExecuteAsync - Disabled Tests
[Fact]
public async Task ExecuteAsync_WhenDisabled_ReturnsEarlyWithoutProcessing()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: false);
// Act
await _synchronizer.ExecuteAsync();
// Assert
_downloadServiceFactoryMock.Verify(
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
Times.Never);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region ExecuteAsync - Path Not Configured Tests
[Fact]
public async Task ExecuteAsync_WhenPathNotConfigured_LogsWarningAndReturns()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: null);
// Act
await _synchronizer.ExecuteAsync();
// Assert
_downloadServiceFactoryMock.Verify(
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
Times.Never);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("path is not configured")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_WhenPathIsWhitespace_LogsWarningAndReturns()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: " ");
// Act
await _synchronizer.ExecuteAsync();
// Assert
_downloadServiceFactoryMock.Verify(
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
Times.Never);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("path is not configured")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region ExecuteAsync - No Clients Tests
[Fact]
public async Task ExecuteAsync_WhenNoQBittorrentClients_LogsDebugAndReturns()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse("pattern1\npattern2");
// Don't add any download clients
// Act
await _synchronizer.ExecuteAsync();
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_WhenOnlyDelugeClients_LogsDebugAndReturns()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse("pattern1\npattern2");
// Add only a Deluge client
await AddDownloadClient(DownloadClientTypeName.Deluge, enabled: true);
// Act
await _synchronizer.ExecuteAsync();
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_WhenDisabledQBittorrentClient_DoesNotProcess()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse("pattern1\npattern2");
// Add a disabled qBittorrent client
await AddDownloadClient(DownloadClientTypeName.qBittorrent, enabled: false);
// Act
await _synchronizer.ExecuteAsync();
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No enabled qBittorrent clients")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region ExecuteAsync - Already Synced Tests
[Fact]
public async Task ExecuteAsync_WhenClientAlreadySynced_SkipsClient()
{
// Arrange
var patterns = "pattern1\npattern2";
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse(patterns);
var clientId = await AddDownloadClient(DownloadClientTypeName.qBittorrent, enabled: true);
// Calculate the expected hash (same as ComputeHash in BlacklistSynchronizer)
var cleanPatterns = string.Join('\n', patterns.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Where(p => !string.IsNullOrWhiteSpace(p)));
var hash = ComputeHash(cleanPatterns);
// Add sync history for this client with the same hash
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
{
Hash = hash,
DownloadClientId = clientId
});
await _dataContext.SaveChangesAsync();
// Act
await _synchronizer.ExecuteAsync();
// Assert
_downloadServiceFactoryMock.Verify(
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
Times.Never);
_loggerMock.Verify(
x => x.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("already synced")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region ExecuteAsync - Dry Run Tests
[Fact]
public async Task ExecuteAsync_UsesDryRunInterceptor()
{
// Arrange
await SetupBlacklistSyncConfig(enabled: true, blacklistPath: "https://example.com/blocklist.txt");
SetupHttpResponse("pattern1\npattern2");
// Act
await _synchronizer.ExecuteAsync();
// Assert - Verify interceptor was called (with Delegate, not Func<object, object, Task>)
_dryRunInterceptorMock.Verify(
d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()),
Times.AtLeastOnce);
}
#endregion
#region Helper Methods
private async Task SetupBlacklistSyncConfig(bool enabled, string? blacklistPath = null)
{
var config = new BlacklistSyncConfig
{
Enabled = enabled,
BlacklistPath = blacklistPath
};
_dataContext.BlacklistSyncConfigs.Add(config);
await _dataContext.SaveChangesAsync();
}
private async Task<Guid> AddDownloadClient(DownloadClientTypeName typeName, bool enabled)
{
var client = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = $"Test {typeName} Client",
TypeName = typeName,
Type = DownloadClientType.Torrent,
Host = new Uri("http://test.example.com"),
Enabled = enabled
};
_dataContext.DownloadClients.Add(client);
await _dataContext.SaveChangesAsync();
return client.Id;
}
private void SetupHttpResponse(string content)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(content)
});
}
private static string ComputeHash(string content)
{
using var sha = System.Security.Cryptography.SHA256.Create();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
byte[] hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
#endregion
}

View File

@@ -1,269 +0,0 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeItemTests
{
[Fact]
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DelugeItem(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var downloadStatus = new DownloadStatus
{
Hash = expectedHash,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var downloadStatus = new DownloadStatus
{
Name = expectedName,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Name = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void IsPrivate_ReturnsCorrectValue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Private = true,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBeTrue();
}
[Fact]
public void Size_ReturnsCorrectValue()
{
// Arrange
var expectedSize = 1024L * 1024 * 1024; // 1GB
var downloadStatus = new DownloadStatus
{
Size = expectedSize,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expectedSize);
}
[Theory]
[InlineData(0, 1024, 0.0)]
[InlineData(512, 1024, 50.0)]
[InlineData(768, 1024, 75.0)]
[InlineData(1024, 1024, 100.0)]
[InlineData(0, 0, 0.0)] // Edge case: zero size
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
{
// Arrange
var downloadStatus = new DownloadStatus
{
TotalDone = totalDone,
Size = size,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker2.example.com/announce" },
new() { Url = "udp://tracker3.example.com:1337/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker1.example.com/announce" },
new() { Url = "udp://tracker1.example.com:1337/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>
{
new() { Url = "http://valid.example.com/announce" },
new() { Url = "invalid-url" },
new() { Url = "" },
new() { Url = null! }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Trackers_WithNullTrackers_ReturnsEmptyList()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Trackers = null!,
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItem(downloadStatus);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,453 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeItemWrapperTests
{
[Fact]
public void Constructor_WithNullDownloadStatus_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DelugeItemWrapper(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var downloadStatus = new DownloadStatus
{
Hash = expectedHash,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var downloadStatus = new DownloadStatus
{
Name = expectedName,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Name = null,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void IsPrivate_ReturnsCorrectValue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Private = true,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBeTrue();
}
[Fact]
public void Size_ReturnsCorrectValue()
{
// Arrange
var expectedSize = 1024L * 1024 * 1024; // 1GB
var downloadStatus = new DownloadStatus
{
Size = expectedSize,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expectedSize);
}
[Theory]
[InlineData(0, 1024, 0.0)]
[InlineData(512, 1024, 50.0)]
[InlineData(768, 1024, 75.0)]
[InlineData(1024, 1024, 100.0)]
[InlineData(0, 0, 0.0)] // Edge case: zero size
public void CompletionPercentage_ReturnsCorrectValue(long totalDone, long size, double expectedPercentage)
{
// Arrange
var downloadStatus = new DownloadStatus
{
TotalDone = totalDone,
Size = size,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Theory]
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
[InlineData(0L, 0L)]
public void DownloadedBytes_ReturnsCorrectValue(long totalDone, long expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
TotalDone = totalDone,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.DownloadedBytes;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(2.0f, 2.0)]
[InlineData(0.5f, 0.5)]
[InlineData(1.0f, 1.0)]
[InlineData(0.0f, 0.0)]
public void Ratio_ReturnsCorrectValue(float ratio, double expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
Ratio = ratio,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Ratio;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(3600UL, 3600L)] // 1 hour
[InlineData(0UL, 0L)]
[InlineData(86400UL, 86400L)] // 1 day
public void Eta_ReturnsCorrectValue(ulong eta, long expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
Eta = eta,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(86400L, 86400L)] // 1 day
[InlineData(0L, 0L)]
[InlineData(3600L, 3600L)] // 1 hour
public void SeedingTimeSeconds_ReturnsCorrectValue(long seedingTime, long expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
SeedingTime = seedingTime,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.SeedingTimeSeconds;
// Assert
result.ShouldBe(expected);
}
[Fact]
public void IsIgnored_WithEmptyList_ReturnsFalse()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.IsIgnored(Array.Empty<string>());
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsIgnored_MatchingHash_ReturnsTrue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
var ignoredDownloads = new[] { "abc123" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingCategory_ReturnsTrue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Label = "test-category",
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
var ignoredDownloads = new[] { "test-category" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Trackers = new List<Tracker>
{
new() { Url = "http://tracker.example.com/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Hash = "abc123",
Name = "Test Torrent",
Label = "some-category",
Trackers = new List<Tracker>
{
new() { Url = "http://tracker.example.com/announce" }
},
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
var ignoredDownloads = new[] { "notmatching" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeFalse();
}
[Theory]
[InlineData(1024L * 1024, 1024L * 1024)] // 1MB/s
[InlineData(0L, 0L)]
[InlineData(500L, 500L)]
public void DownloadSpeed_ReturnsCorrectValue(long downloadSpeed, long expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
DownloadSpeed = downloadSpeed,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.DownloadSpeed;
// Assert
result.ShouldBe(expected);
}
[Fact]
public void Category_Setter_SetsLabel()
{
// Arrange
var downloadStatus = new DownloadStatus
{
Label = "original-category",
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
wrapper.Category = "new-category";
// Assert
wrapper.Category.ShouldBe("new-category");
downloadStatus.Label.ShouldBe("new-category");
}
[Theory]
[InlineData("Downloading", true)]
[InlineData("downloading", true)]
[InlineData("DOWNLOADING", true)]
[InlineData("Seeding", false)]
[InlineData("Paused", false)]
[InlineData(null, false)]
public void IsDownloading_ReturnsCorrectValue(string? state, bool expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
State = state,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.IsDownloading();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData("Downloading", 0, 0UL, true)] // Downloading with no speed and no ETA = stalled
[InlineData("Downloading", 1000, 0UL, false)] // Has download speed = not stalled
[InlineData("Downloading", 0, 100UL, false)] // Has ETA = not stalled
[InlineData("Downloading", 1000, 100UL, false)] // Has both = not stalled
[InlineData("Seeding", 0, 0UL, false)] // Not downloading state = not stalled
[InlineData("Paused", 0, 0UL, false)] // Not downloading state = not stalled
[InlineData(null, 0, 0UL, false)] // Null state = not stalled
public void IsStalled_ReturnsCorrectValue(string? state, long downloadSpeed, ulong eta, bool expected)
{
// Arrange
var downloadStatus = new DownloadStatus
{
State = state,
DownloadSpeed = downloadSpeed,
Eta = eta,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.IsStalled();
// Assert
result.ShouldBe(expected);
}
}

View File

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

View File

@@ -0,0 +1,112 @@
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.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceFixture : IDisposable
{
public Mock<ILogger<DelugeService>> Logger { 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 Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IDelugeClientWrapper> ClientWrapper { get; }
public DelugeServiceFixture()
{
Logger = new Mock<ILogger<DelugeService>>();
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 = new Mock<IBlocklistProvider>();
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,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
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()
{
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,499 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
private readonly DelugeServiceFixture _fixture;
public DelugeServiceTests(DelugeServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_BasicScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentNotFound_ReturnsEmptyResult()
{
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync((DownloadStatus?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = true,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.True(result.IsPrivate);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.False(result.IsPrivate);
}
}
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task AllFilesUnwanted_DeletesFromClient()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0 } },
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 1 } }
}
});
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task SomeFilesWanted_DoesNotRemove()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0 } },
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string category = "test-category";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Label = category,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByTrackerDomain_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string trackerDomain = "tracker.example.com";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>
{
new Tracker { Url = $"https://{trackerDomain}/announce" }
},
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NotDownloadingState_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Seeding",
Private = false,
DownloadSpeed = 0,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
}
[Fact]
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 0,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()), Times.Never);
}
}
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : DelugeServiceTests
{
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
Private = false,
DownloadSpeed = 1000,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var downloadStatus = new DownloadStatus
{
Hash = hash,
Name = "Test Torrent",
State = "Downloading",
DownloadSpeed = 0,
Eta = 0,
Private = false,
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentStatus(hash))
.ReturnsAsync(downloadStatus);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFiles(hash))
.ReturnsAsync(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
}

View File

@@ -0,0 +1,281 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DownloadServiceFactoryTests : IDisposable
{
private readonly Mock<ILogger<DownloadServiceFactory>> _loggerMock;
private readonly IServiceProvider _serviceProvider;
private readonly DownloadServiceFactory _factory;
private readonly MemoryCache _memoryCache;
public DownloadServiceFactoryTests()
{
_loggerMock = new Mock<ILogger<DownloadServiceFactory>>();
var services = new ServiceCollection();
// Use real MemoryCache - mocks don't work properly with cache operations
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
services.AddSingleton<IMemoryCache>(_memoryCache);
// Register loggers
services.AddSingleton(Mock.Of<ILogger<QBitService>>());
services.AddSingleton(Mock.Of<ILogger<DelugeService>>());
services.AddSingleton(Mock.Of<ILogger<TransmissionService>>());
services.AddSingleton(Mock.Of<ILogger<UTorrentService>>());
services.AddSingleton(Mock.Of<IFilenameEvaluator>());
services.AddSingleton(Mock.Of<IStriker>());
services.AddSingleton(Mock.Of<IDryRunInterceptor>());
services.AddSingleton(Mock.Of<IHardLinkFileService>());
// IDynamicHttpClientProvider must return a real HttpClient for download services
var httpClientProviderMock = new Mock<IDynamicHttpClientProvider>();
httpClientProviderMock.Setup(p => p.CreateClient(It.IsAny<DownloadClientConfig>())).Returns(new HttpClient());
services.AddSingleton(httpClientProviderMock.Object);
services.AddSingleton(Mock.Of<IRuleEvaluator>());
services.AddSingleton(Mock.Of<IRuleManager>());
// UTorrentService needs ILoggerFactory
services.AddLogging();
// EventPublisher requires specific constructor arguments
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var eventsContext = new EventsContext(eventsContextOptions);
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
services.AddSingleton<IEventPublisher>(new EventPublisher(
eventsContext,
hubContextMock.Object,
Mock.Of<ILogger<EventPublisher>>(),
Mock.Of<INotificationPublisher>(),
Mock.Of<IDryRunInterceptor>()));
// BlocklistProvider requires specific constructor arguments
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
services.AddSingleton<IBlocklistProvider>(new BlocklistProvider(
Mock.Of<ILogger<BlocklistProvider>>(),
scopeFactoryMock.Object,
_memoryCache));
_serviceProvider = services.BuildServiceProvider();
_factory = new DownloadServiceFactory(_loggerMock.Object, _serviceProvider);
}
public void Dispose()
{
_memoryCache.Dispose();
}
#region GetDownloadService Tests
[Fact]
public void GetDownloadService_QBittorrent_ReturnsQBitService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<QBitService>(service);
}
[Fact]
public void GetDownloadService_Deluge_ReturnsDelugeService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.Deluge);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<DelugeService>(service);
}
[Fact]
public void GetDownloadService_Transmission_ReturnsTransmissionService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.Transmission);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<TransmissionService>(service);
}
[Fact]
public void GetDownloadService_UTorrent_ReturnsUTorrentService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.uTorrent);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType<UTorrentService>(service);
}
[Fact]
public void GetDownloadService_UnsupportedType_ThrowsNotSupportedException()
{
// Arrange
var config = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Unsupported Client",
TypeName = (DownloadClientTypeName)999, // Invalid type
Type = DownloadClientType.Torrent,
Host = new Uri("http://test.example.com"),
Enabled = true
};
// Act & Assert
var exception = Assert.Throws<NotSupportedException>(() => _factory.GetDownloadService(config));
Assert.Contains("not supported", exception.Message);
}
[Fact]
public void GetDownloadService_DisabledClient_LogsWarningButReturnsService()
{
// Arrange
var config = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "Disabled qBittorrent",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://test.example.com"),
Enabled = false
};
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("disabled")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public void GetDownloadService_EnabledClient_DoesNotLogWarning()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
_loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Never);
}
[Theory]
[InlineData(DownloadClientTypeName.qBittorrent, typeof(QBitService))]
[InlineData(DownloadClientTypeName.Deluge, typeof(DelugeService))]
[InlineData(DownloadClientTypeName.Transmission, typeof(TransmissionService))]
[InlineData(DownloadClientTypeName.uTorrent, typeof(UTorrentService))]
public void GetDownloadService_AllSupportedTypes_ReturnCorrectServiceType(
DownloadClientTypeName typeName, Type expectedServiceType)
{
// Arrange
var config = CreateClientConfig(typeName);
// Act
var service = _factory.GetDownloadService(config);
// Assert
Assert.NotNull(service);
Assert.IsType(expectedServiceType, service);
}
[Fact]
public void GetDownloadService_ReturnsNewInstanceEachTime()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.qBittorrent);
// Act
var service1 = _factory.GetDownloadService(config);
var service2 = _factory.GetDownloadService(config);
// Assert
Assert.NotSame(service1, service2);
}
#endregion
#region Helper Methods
private static DownloadClientConfig CreateClientConfig(DownloadClientTypeName typeName)
{
return new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = $"Test {typeName} Client",
TypeName = typeName,
Type = DownloadClientType.Torrent,
Host = new Uri("http://test.example.com"),
Enabled = true
};
}
#endregion
}

View File

@@ -5,7 +5,7 @@ using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class QBitItemTests
public class QBitItemWrapperTests
{
[Fact]
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
@@ -14,7 +14,7 @@ public class QBitItemTests
var trackers = new List<TorrentTracker>();
// Act & Assert
Should.Throw<ArgumentNullException>(() => new QBitItem(null!, trackers, false));
Should.Throw<ArgumentNullException>(() => new QBitItemWrapper(null!, trackers, false));
}
[Fact]
@@ -24,7 +24,7 @@ public class QBitItemTests
var torrentInfo = new TorrentInfo();
// Act & Assert
Should.Throw<ArgumentNullException>(() => new QBitItem(torrentInfo, null!, false));
Should.Throw<ArgumentNullException>(() => new QBitItemWrapper(torrentInfo, null!, false));
}
[Fact]
@@ -34,7 +34,7 @@ public class QBitItemTests
var expectedHash = "test-hash-123";
var torrentInfo = new TorrentInfo { Hash = expectedHash };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Hash;
@@ -49,7 +49,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Hash = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Hash;
@@ -65,7 +65,7 @@ public class QBitItemTests
var expectedName = "Test Torrent";
var torrentInfo = new TorrentInfo { Name = expectedName };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Name;
@@ -80,7 +80,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Name = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Name;
@@ -95,7 +95,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, true);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, true);
// Act
var result = wrapper.IsPrivate;
@@ -111,7 +111,7 @@ public class QBitItemTests
var expectedSize = 1024L * 1024 * 1024; // 1GB
var torrentInfo = new TorrentInfo { Size = expectedSize };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Size;
@@ -126,7 +126,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Size = 0 };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Size;
@@ -145,7 +145,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Progress = progress };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.CompletionPercentage;
@@ -155,86 +155,210 @@ public class QBitItemTests
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
public void DownloadedBytes_ReturnsCorrectValue()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker2.example.com/announce" },
new() { Url = "udp://tracker3.example.com:1337/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker1.example.com:8080/announce" },
new() { Url = "https://tracker1.example.com/announce" },
new() { Url = "udp://tracker1.example.com:1337/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var torrentInfo = new TorrentInfo();
var trackers = new List<TorrentTracker>
{
new() { Url = "http://valid.example.com/announce" },
new() { Url = "invalid-url" },
new() { Url = "" },
new() { Url = null }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo();
var expectedDownloaded = 1024L * 1024 * 500; // 500MB
var torrentInfo = new TorrentInfo { Downloaded = expectedDownloaded };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Trackers;
var result = wrapper.DownloadedBytes;
// Assert
result.ShouldBe(expectedDownloaded);
}
[Fact]
public void DownloadedBytes_WithNullValue_ReturnsZero()
{
// Arrange
var torrentInfo = new TorrentInfo { Downloaded = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.DownloadedBytes;
// Assert
result.ShouldBe(0);
}
[Fact]
public void DownloadSpeed_ReturnsCorrectValue()
{
// Arrange
var expectedSpeed = 1024 * 512; // 512 KB/s
var torrentInfo = new TorrentInfo { DownloadSpeed = expectedSpeed };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.DownloadSpeed;
// Assert
result.ShouldBe(expectedSpeed);
}
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
[InlineData(1.0)]
[InlineData(2.5)]
public void Ratio_ReturnsCorrectValue(double expectedRatio)
{
// Arrange
var torrentInfo = new TorrentInfo { Ratio = expectedRatio };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Ratio;
// Assert
result.ShouldBe(expectedRatio);
}
[Fact]
public void Eta_ReturnsCorrectValue()
{
// Arrange
var expectedEta = TimeSpan.FromMinutes(30);
var torrentInfo = new TorrentInfo { EstimatedTime = expectedEta };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe((long)expectedEta.TotalSeconds);
}
[Fact]
public void Eta_WithNullValue_ReturnsZero()
{
// Arrange
var torrentInfo = new TorrentInfo { EstimatedTime = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe(0);
}
[Fact]
public void SeedingTimeSeconds_ReturnsCorrectValue()
{
// Arrange
var expectedTime = TimeSpan.FromHours(5);
var torrentInfo = new TorrentInfo { SeedingTime = expectedTime };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.SeedingTimeSeconds;
// Assert
result.ShouldBe((long)expectedTime.TotalSeconds);
}
[Fact]
public void SeedingTimeSeconds_WithNullValue_ReturnsZero()
{
// Arrange
var torrentInfo = new TorrentInfo { SeedingTime = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.SeedingTimeSeconds;
// Assert
result.ShouldBe(0);
}
[Fact]
public void Tags_ReturnsCorrectValue()
{
// Arrange
var expectedTags = new List<string> { "tag1", "tag2", "tag3" };
var torrentInfo = new TorrentInfo { Tags = expectedTags.AsReadOnly() };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Tags;
// Assert
result.ShouldBe(expectedTags);
}
[Fact]
public void Tags_WithNullValue_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo { Tags = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Tags;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Tags_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo { Tags = new List<string>().AsReadOnly() };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Tags;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Category_ReturnsCorrectValue()
{
// Arrange
var expectedCategory = "movies";
var torrentInfo = new TorrentInfo { Category = expectedCategory };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Category;
// Assert
result.ShouldBe(expectedCategory);
}
[Fact]
public void Category_WithNullValue_ReturnsNull()
{
// Arrange
var torrentInfo = new TorrentInfo { Category = null };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.Category;
// Assert
result.ShouldBeNull();
}
// State checking method tests
[Theory]
[InlineData(TorrentState.Downloading, true)]
@@ -247,7 +371,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsDownloading();
@@ -266,7 +390,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsStalled();
@@ -286,7 +410,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsSeeding();
@@ -295,101 +419,6 @@ public class QBitItemTests
result.ShouldBe(expected);
}
[Theory]
[InlineData(0.0, false)]
[InlineData(0.5, false)]
[InlineData(0.99, false)]
[InlineData(1.0, true)]
public void IsCompleted_ReturnsCorrectValue(double progress, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { Progress = progress };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsCompleted();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.PausedDownload, true)]
[InlineData(TorrentState.PausedUpload, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsPaused_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsPaused();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.QueuedDownload, true)]
[InlineData(TorrentState.QueuedUpload, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsQueued_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsQueued();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.CheckingDownload, true)]
[InlineData(TorrentState.CheckingUpload, true)]
[InlineData(TorrentState.CheckingResumeData, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsChecking_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsChecking();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.Allocating, true)]
[InlineData(TorrentState.Downloading, false)]
[InlineData(TorrentState.Uploading, false)]
public void IsAllocating_ReturnsCorrectValue(TorrentState state, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
// Act
var result = wrapper.IsAllocating();
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(TorrentState.FetchingMetadata, true)]
[InlineData(TorrentState.ForcedFetchingMetadata, true)]
@@ -400,7 +429,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { State = state };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsMetadataDownloading();
@@ -415,7 +444,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.IsIgnored(Array.Empty<string>());
@@ -430,7 +459,7 @@ public class QBitItemTests
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "abc123" };
// Act
@@ -440,16 +469,62 @@ public class QBitItemTests
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTag_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123",
Tags = new List<string> { "test-tag" }.AsReadOnly()
};
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "test-tag" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingCategory_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123",
Category = "test-category"
};
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "test-category" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123"
};
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
@@ -463,12 +538,18 @@ public class QBitItemTests
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
var torrentInfo = new TorrentInfo
{
Name = "Test Torrent",
Hash = "abc123",
Category = "some-category",
Tags = new List<string> { "some-tag" }.AsReadOnly()
};
var trackers = new List<TorrentTracker>
{
new() { Url = "http://tracker.example.com/announce" }
};
var wrapper = new QBitItem(torrentInfo, trackers, false);
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
var ignoredDownloads = new[] { "notmatching" };
// Act

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
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.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class QBitServiceFixture : IDisposable
{
public Mock<ILogger<QBitService>> Logger { 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 Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IQBittorrentClientWrapper> ClientWrapper { get; }
public QBitServiceFixture()
{
Logger = new Mock<ILogger<QBitService>>();
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 =new Mock<IBlocklistProvider>();
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,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
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()
{
GC.SuppressFinalize(this);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,239 +0,0 @@
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Shouldly;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionItemTests
{
[Fact]
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new TransmissionItem(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var torrentInfo = new TorrentInfo { HashString = expectedHash };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = null };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var torrentInfo = new TorrentInfo { Name = expectedName };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = null };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
[InlineData(null, false)]
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(0L, 1024L, 0.0)]
[InlineData(512L, 1024L, 50.0)]
[InlineData(768L, 1024L, 75.0)]
[InlineData(1024L, 1024L, 100.0)]
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
[InlineData(512L, null, 0.0)] // Edge case: null total size
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
{
// Arrange
var torrentInfo = new TorrentInfo
{
DownloadedEver = downloadedEver,
TotalSize = totalSize
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker1.example.com:8080/announce" },
new() { Announce = "https://tracker2.example.com/announce" },
new() { Announce = "udp://tracker3.example.com:1337/announce" }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker1.example.com:8080/announce" },
new() { Announce = "https://tracker1.example.com/announce" },
new() { Announce = "udp://tracker1.example.com:1337/announce" }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://valid.example.com/announce" },
new() { Announce = "invalid-url" },
new() { Announce = "" },
new() { Announce = null }
}
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = new TransmissionTorrentTrackers[0]
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
[Fact]
public void Trackers_WithNullTrackers_ReturnsEmptyList()
{
// Arrange
var torrentInfo = new TorrentInfo
{
Trackers = null
};
var wrapper = new TransmissionItem(torrentInfo);
// Act
var result = wrapper.Trackers;
// Assert
result.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,307 @@
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Shouldly;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionItemWrapperTests
{
[Fact]
public void Constructor_WithNullTorrentInfo_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new TransmissionItemWrapper(null!));
}
[Fact]
public void Hash_ReturnsCorrectValue()
{
// Arrange
var expectedHash = "test-hash-123";
var torrentInfo = new TorrentInfo { HashString = expectedHash };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(expectedHash);
}
[Fact]
public void Hash_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = null };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Hash;
// Assert
result.ShouldBe(string.Empty);
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Arrange
var expectedName = "Test Torrent";
var torrentInfo = new TorrentInfo { Name = expectedName };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(expectedName);
}
[Fact]
public void Name_WithNullValue_ReturnsEmptyString()
{
// Arrange
var torrentInfo = new TorrentInfo { Name = null };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Name;
// Assert
result.ShouldBe(string.Empty);
}
[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
[InlineData(null, false)]
public void IsPrivate_ReturnsCorrectValue(bool? isPrivate, bool expected)
{
// Arrange
var torrentInfo = new TorrentInfo { IsPrivate = isPrivate };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.IsPrivate;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(1024L * 1024 * 1024, 1024L * 1024 * 1024)] // 1GB
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void Size_ReturnsCorrectValue(long? totalSize, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { TotalSize = totalSize };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Size;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(0L, 1024L, 0.0)]
[InlineData(512L, 1024L, 50.0)]
[InlineData(768L, 1024L, 75.0)]
[InlineData(1024L, 1024L, 100.0)]
[InlineData(0L, 0L, 0.0)] // Edge case: zero size
[InlineData(null, 1024L, 0.0)] // Edge case: null downloaded
[InlineData(512L, null, 0.0)] // Edge case: null total size
public void CompletionPercentage_ReturnsCorrectValue(long? downloadedEver, long? totalSize, double expectedPercentage)
{
// Arrange
var torrentInfo = new TorrentInfo
{
DownloadedEver = downloadedEver,
TotalSize = totalSize
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.CompletionPercentage;
// Assert
result.ShouldBe(expectedPercentage);
}
[Theory]
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void DownloadedBytes_ReturnsCorrectValue(long? downloadedEver, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { DownloadedEver = downloadedEver };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.DownloadedBytes;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(1024L, 512L, 2.0)] // Uploaded more than downloaded
[InlineData(512L, 1024L, 0.5)] // Uploaded less than downloaded
[InlineData(1024L, 1024L, 1.0)] // Equal
[InlineData(0L, 1024L, 0.0)] // No upload
[InlineData(1024L, 0L, 0.0)] // No download
[InlineData(null, 1024L, 0.0)] // Null upload
[InlineData(1024L, null, 0.0)] // Null download
[InlineData(null, null, 0.0)] // Both null
public void Ratio_ReturnsCorrectValue(long? uploadedEver, long? downloadedEver, double expected)
{
// Arrange
var torrentInfo = new TorrentInfo
{
UploadedEver = uploadedEver,
DownloadedEver = downloadedEver
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Ratio;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(3600L, 3600L)] // 1 hour
[InlineData(0L, 0L)]
[InlineData(-1L, -1L)] // Unknown/infinite
[InlineData(null, 0L)]
public void Eta_ReturnsCorrectValue(long? eta, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { Eta = eta };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(86400L, 86400L)] // 1 day
[InlineData(0L, 0L)]
[InlineData(null, 0L)]
public void SeedingTimeSeconds_ReturnsCorrectValue(long? secondsSeeding, long expected)
{
// Arrange
var torrentInfo = new TorrentInfo { SecondsSeeding = secondsSeeding };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.SeedingTimeSeconds;
// Assert
result.ShouldBe(expected);
}
[Fact]
public void IsIgnored_WithEmptyList_ReturnsFalse()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = "abc123", Name = "Test Torrent" };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.IsIgnored(Array.Empty<string>());
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsIgnored_MatchingHash_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo { HashString = "abc123", Name = "Test Torrent" };
var wrapper = new TransmissionItemWrapper(torrentInfo);
var ignoredDownloads = new[] { "abc123" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingCategory_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo
{
HashString = "abc123",
Name = "Test Torrent",
DownloadDir = "/downloads/test-category"
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
var ignoredDownloads = new[] { "test-category" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var torrentInfo = new TorrentInfo
{
HashString = "abc123",
Name = "Test Torrent",
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker.example.com/announce" }
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var torrentInfo = new TorrentInfo
{
HashString = "abc123",
Name = "Test Torrent",
Labels = new[] { "some-category" },
Trackers = new TransmissionTorrentTrackers[]
{
new() { Announce = "http://tracker.example.com/announce" }
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
var ignoredDownloads = new[] { "notmatching" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeFalse();
}
}

View File

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

View File

@@ -0,0 +1,112 @@
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.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceFixture : IDisposable
{
public Mock<ILogger<TransmissionService>> Logger { 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 Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<ITransmissionClientWrapper> ClientWrapper { get; }
public TransmissionServiceFixture()
{
Logger = new Mock<ILogger<TransmissionService>>();
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 = new Mock<IBlocklistProvider>();
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,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
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()
{
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,718 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Moq;
using Transmission.API.RPC.Entity;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture>
{
private readonly TransmissionServiceFixture _fixture;
public TransmissionServiceTests(TransmissionServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_BasicScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentNotFound_ReturnsEmptyResult()
{
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync((TransmissionTorrents?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = true,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.True(result.IsPrivate);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.False(result.IsPrivate);
}
}
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task AllFilesUnwanted_DeletesFromClient()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
FileStats = new[]
{
new TransmissionTorrentFileStats { Wanted = false },
new TransmissionTorrentFileStats { Wanted = false }
}
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task SomeFilesWanted_DoesNotRemove()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
RateDownload = 1000,
FileStats = new[]
{
new TransmissionTorrentFileStats { Wanted = false },
new TransmissionTorrentFileStats { Wanted = true }
}
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string category = "test-category";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
Labels = new[] { category },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_MissingFileStatsScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_MissingFileStatsScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FilesWithMissingWantedStatus_DoesNotRemove()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
RateDownload = 1000,
FileStats = new[]
{
new TransmissionTorrentFileStats { Wanted = null },
new TransmissionTorrentFileStats { Wanted = false }
}
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NotDownloadingState_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 6,
IsPrivate = false,
RateDownload = 0,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
}
[Fact]
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
RateDownload = 0,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()), Times.Never);
}
}
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : TransmissionServiceTests
{
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
IsPrivate = false,
RateDownload = 1000,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentInfo = new TorrentInfo
{
Id = 1,
HashString = hash,
Name = "Test Torrent",
Status = 4,
RateDownload = 0,
Eta = 0,
IsPrivate = false,
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
};
var torrents = new TransmissionTorrents
{
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
}

View File

@@ -109,95 +109,173 @@ public class UTorrentItemWrapperTests
result.ShouldBe(expectedPercentage);
}
[Fact]
public void Trackers_WithValidUrls_ReturnsHostNames()
[Theory]
[InlineData(1024L * 1024 * 100, 1024L * 1024 * 100)] // 100MB
[InlineData(0L, 0L)]
public void DownloadedBytes_ReturnsCorrectValue(long downloaded, long expected)
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker2.example.com/announce\r\nudp://tracker3.example.com:1337/announce"
};
var torrentItem = new UTorrentItem { Downloaded = downloaded };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
var result = wrapper.DownloadedBytes;
// Assert
result.Count.ShouldBe(3);
result.ShouldContain("tracker1.example.com");
result.ShouldContain("tracker2.example.com");
result.ShouldContain("tracker3.example.com");
result.ShouldBe(expected);
}
[Theory]
[InlineData(2000, 2.0)] // 2000 permille = 2.0 ratio
[InlineData(500, 0.5)] // 500 permille = 0.5 ratio
[InlineData(1000, 1.0)] // 1000 permille = 1.0 ratio
[InlineData(0, 0.0)] // No ratio
public void Ratio_ReturnsCorrectValue(int ratioRaw, double expected)
{
// Arrange
var torrentItem = new UTorrentItem { RatioRaw = ratioRaw };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Ratio;
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData(3600, 3600L)] // 1 hour
[InlineData(0, 0L)]
[InlineData(-1, -1L)] // Unknown/infinite
public void Eta_ReturnsCorrectValue(int eta, long expected)
{
// Arrange
var torrentItem = new UTorrentItem { ETA = eta };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Eta;
// Assert
result.ShouldBe(expected);
}
[Fact]
public void Trackers_WithDuplicateHosts_ReturnsDistinctHosts()
public void SeedingTimeSeconds_WithCompletedDate_ReturnsPositiveValue()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker1.example.com:8080/announce\r\nhttps://tracker1.example.com/announce\r\nudp://tracker1.example.com:1337/announce"
};
// Arrange - Set DateCompleted to 1 hour ago
var oneHourAgo = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
var torrentItem = new UTorrentItem { DateCompleted = oneHourAgo };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
var result = wrapper.SeedingTimeSeconds;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("tracker1.example.com");
// Assert - Should be approximately 3600 seconds (1 hour), allow some tolerance
result.ShouldBeInRange(3599L, 3601L);
}
[Fact]
public void Trackers_WithInvalidUrls_SkipsInvalidEntries()
public void SeedingTimeSeconds_WithNoCompletedDate_ReturnsZero()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = "http://valid.example.com/announce\r\ninvalid-url\r\n\r\n "
};
// Arrange - DateCompleted = 0 means not completed
var torrentItem = new UTorrentItem { DateCompleted = 0 };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
var result = wrapper.SeedingTimeSeconds;
// Assert
result.Count.ShouldBe(1);
result.ShouldContain("valid.example.com");
result.ShouldBe(0L);
}
[Fact]
public void Trackers_WithEmptyList_ReturnsEmptyList()
public void IsIgnored_WithEmptyList_ReturnsFalse()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties
{
Trackers = ""
};
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
// Act
var result = wrapper.Trackers;
var result = wrapper.IsIgnored(Array.Empty<string>());
// Assert
result.ShouldBeEmpty();
result.ShouldBeFalse();
}
[Fact]
public void Trackers_WithNullTrackerList_ReturnsEmptyList()
public void IsIgnored_MatchingHash_ReturnsTrue()
{
// Arrange
var torrentItem = new UTorrentItem();
var torrentProperties = new UTorrentProperties(); // Trackers defaults to empty string
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
var ignoredDownloads = new[] { "abc123" };
// Act
var result = wrapper.Trackers;
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeEmpty();
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingCategory_ReturnsTrue()
{
// Arrange
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent", Label = "test-category" };
var torrentProperties = new UTorrentProperties();
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
var ignoredDownloads = new[] { "test-category" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_MatchingTracker_ReturnsTrue()
{
// Arrange
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent" };
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker.example.com/announce"
};
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
var ignoredDownloads = new[] { "tracker.example.com" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsIgnored_NotMatching_ReturnsFalse()
{
// Arrange
var torrentItem = new UTorrentItem { Hash = "abc123", Name = "Test Torrent", Label = "some-category" };
var torrentProperties = new UTorrentProperties
{
Trackers = "http://tracker.example.com/announce"
};
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties);
var ignoredDownloads = new[] { "notmatching" };
// Act
var result = wrapper.IsIgnored(ignoredDownloads);
// Assert
result.ShouldBeFalse();
}
}

View File

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

View File

@@ -0,0 +1,112 @@
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.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceFixture : IDisposable
{
public Mock<ILogger<UTorrentService>> Logger { 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 Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IUTorrentClientWrapper> ClientWrapper { get; }
public UTorrentServiceFixture()
{
Logger = new Mock<ILogger<UTorrentService>>();
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 = new Mock<IBlocklistProvider>();
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,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
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()
{
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,613 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
{
private readonly UTorrentServiceFixture _fixture;
public UTorrentServiceTests(UTorrentServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_BasicScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentNotFound_ReturnsEmptyResult()
{
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync((UTorrentItem?)null);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked = 1 + 8
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = -1, // -1 means private torrent
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.True(result.IsPrivate);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked = 1 + 8
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1, // 1 means public torrent (PEX enabled)
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.False(result.IsPrivate);
}
}
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task AllFilesUnwanted_DeletesFromClient()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 0, Index = 1, Size = 2000, Downloaded = 0 }
});
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task SomeFilesWanted_DoesNotRemove()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 0, Index = 0, Size = 1000, Downloaded = 0 },
new UTorrentFile { Name = "file2.mkv", Priority = 1, Index = 1, Size = 2000, Downloaded = 1000 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_IgnoredDownloadScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentIgnoredByHash_ReturnsEmptyResult()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByCategory_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string category = "test-category";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000,
Label = category
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIgnoredByTrackerDomain_ReturnsEmptyResult()
{
const string hash = "test-hash";
const string trackerDomain = "tracker.example.com";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = $"https://{trackerDomain}/announce\r\n"
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_ExceptionHandlingScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_ExceptionHandlingScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task GetTorrentFilesAsync_ThrowsException_ContinuesProcessing()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9,
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_StateCheckScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_StateCheckScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NotDownloadingState_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 32, // Paused
DownloadSpeed = 0
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
}
[Fact]
public async Task ZeroDownloadSpeed_SkipsSlowCheck()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked
DownloadSpeed = 0
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()), Times.Never);
}
}
public class ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios : UTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_SlowAndStalledScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked
DownloadSpeed = 1000
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
{
const string hash = "test-hash";
var sut = _fixture.CreateSut();
var torrentItem = new UTorrentItem
{
Hash = hash,
Name = "Test Torrent",
Status = 9, // Started + Checked
DownloadSpeed = 0,
ETA = 0
};
var torrentProperties = new UTorrentProperties
{
Hash = hash,
Pex = 1,
Trackers = ""
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(torrentItem);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentPropertiesAsync(hash))
.ReturnsAsync(torrentProperties);
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
}

View File

@@ -0,0 +1,166 @@
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(),
JobRunId = Guid.NewGuid()
};
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(),
JobRunId = Guid.NewGuid()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://radarr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
private static Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>> CreateConsumeContextMock(DownloadHuntRequest<SearchItem> message)
{
var mock = new Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>>();
mock.Setup(c => c.Message).Returns(message);
return mock;
}
#endregion
}

View File

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

View File

@@ -0,0 +1,231 @@
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,
JobRunId = Guid.NewGuid()
};
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,
JobRunId = Guid.NewGuid()
};
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,
JobRunId = Guid.NewGuid()
};
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,
JobRunId = Guid.NewGuid()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://radarr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
private static Mock<ConsumeContext<QueueItemRemoveRequest<SearchItem>>> CreateConsumeContextMock(QueueItemRemoveRequest<SearchItem> message)
{
var mock = new Mock<ConsumeContext<QueueItemRemoveRequest<SearchItem>>>();
mock.Setup(c => c.Message).Returns(message);
return mock;
}
#endregion
}

View File

@@ -0,0 +1,475 @@
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.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
public class QueueItemRemoverTests : IDisposable
{
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
private readonly Mock<IBus> _busMock;
private readonly MemoryCache _memoryCache;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly EventPublisher _eventPublisher;
private readonly EventsContext _eventsContext;
private readonly QueueItemRemover _queueItemRemover;
public QueueItemRemoverTests()
{
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
_busMock = new Mock<IBus>();
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(_arrClientMock.Object);
// Create real EventPublisher with mocked dependencies
_eventsContext = TestEventsContextFactory.Create();
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 skip actual database saves (these tests verify QueueItemRemover, not EventPublisher)
dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns(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,
_eventsContext
);
// Clear static RecurringHashes before each test
Striker.RecurringHashes.Clear();
}
public void Dispose()
{
_memoryCache.Dispose();
_eventsContext.Dispose();
Striker.RecurringHashes.Clear();
}
#region RemoveQueueItemAsync - Success Tests
[Fact]
public async Task RemoveQueueItemAsync_Success_DeletesQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
request.Instance,
request.Record,
request.RemoveFromClient,
request.DeleteReason), Times.Once);
}
[Fact]
public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
DownloadHuntRequest<SearchItem>? capturedRequest = null;
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
_busMock
.Setup(b => b.Publish(It.IsAny<DownloadHuntRequest<SearchItem>>(), It.IsAny<CancellationToken>()))
.Callback<DownloadHuntRequest<SearchItem>, CancellationToken>((r, _) => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest!.InstanceType);
Assert.Equal(request.Instance, capturedRequest.Instance);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
}
[Fact]
public async Task RemoveQueueItemAsync_Success_ClearsDownloadMarkedForRemovalCache()
{
// Arrange
var request = CreateRemoveRequest();
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
_memoryCache.Set(cacheKey, true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task RemoveQueueItemAsync_UsesCorrectClientForInstanceType(InstanceType instanceType)
{
// Arrange
var request = CreateRemoveRequest(instanceType);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
}
#endregion
#region RemoveQueueItemAsync - Recurring Hash Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
var hash = request.Record.DownloadId.ToLowerInvariant();
Striker.RecurringHashes.TryAdd(hash, null);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_RemovesHashFromRecurring()
{
// Arrange
var request = CreateRemoveRequest();
var hash = request.Record.DownloadId.ToLowerInvariant();
Striker.RecurringHashes.TryAdd(hash, null);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
}
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
}
#endregion
#region RemoveQueueItemAsync - HTTP Error Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenNotFoundError_ThrowsWithItemAlreadyDeletedMessage()
{
// Arrange
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Contains("might have already been deleted", exception.Message);
Assert.Contains(request.InstanceType.ToString(), exception.Message);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenNotFoundError_ClearsCacheInFinally()
{
// Arrange
var request = CreateRemoveRequest();
var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}";
_memoryCache.Set(cacheKey, true);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
// Cache should be cleared in finally block
Assert.False(_memoryCache.TryGetValue(cacheKey, out _));
}
[Fact]
public async Task RemoveQueueItemAsync_WhenOtherHttpError_Rethrows()
{
// Arrange
var request = CreateRemoveRequest();
var originalException = new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<HttpRequestException>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Same(originalException, exception);
}
[Fact]
public async Task RemoveQueueItemAsync_WhenNonHttpError_Rethrows()
{
// Arrange
var request = CreateRemoveRequest();
var originalException = new InvalidOperationException("Some other error");
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => _queueItemRemover.RemoveQueueItemAsync(request));
Assert.Same(originalException, exception);
}
#endregion
#region RemoveQueueItemAsync - Delete Reason Tests
[Theory]
[InlineData(DeleteReason.Stalled)]
[InlineData(DeleteReason.FailedImport)]
[InlineData(DeleteReason.SlowSpeed)]
[InlineData(DeleteReason.SlowTime)]
[InlineData(DeleteReason.DownloadingMetadata)]
[InlineData(DeleteReason.MalwareFileFound)]
public async Task RemoveQueueItemAsync_PassesCorrectDeleteReason(DeleteReason deleteReason)
{
// Arrange
var request = CreateRemoveRequest(deleteReason: deleteReason);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
deleteReason), Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RemoveQueueItemAsync_PassesCorrectRemoveFromClientFlag(bool removeFromClient)
{
// Arrange
var request = CreateRemoveRequest(removeFromClient: removeFromClient);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
removeFromClient,
It.IsAny<DeleteReason>()), Times.Once);
}
#endregion
#region Helper Methods
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
InstanceType instanceType = InstanceType.Sonarr,
bool removeFromClient = true,
DeleteReason deleteReason = DeleteReason.Stalled)
{
return new QueueItemRemoveRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason,
JobRunId = Guid.NewGuid()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123DEF456"
};
}
#endregion
}

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

File diff suppressed because it is too large Load Diff

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