mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-12 05:19:43 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5e7c0819 | ||
|
|
d875d88191 | ||
|
|
447db6990a | ||
|
|
4e9d20db0a | ||
|
|
53fc5eff3b | ||
|
|
69fa09e23a | ||
|
|
3360b7a849 | ||
|
|
80b46df8e5 | ||
|
|
88aa71c343 | ||
|
|
7b80e038cc | ||
|
|
ef280ec398 | ||
|
|
81f6de03e7 | ||
|
|
17c3a6b02a | ||
|
|
4903b3137b | ||
|
|
868406c95c | ||
|
|
7122b16a7a | ||
|
|
b9fbac4ddc | ||
|
|
33e948d1e7 | ||
|
|
9f551f151e | ||
|
|
9447cb37c0 | ||
|
|
8183b324a0 | ||
|
|
a6a25de19c | ||
|
|
51a2a1b391 | ||
|
|
d7ab81ddcf | ||
|
|
8da07d4e93 |
No files matched your search
@@ -1,83 +1,122 @@
|
||||
# Cleanuparr - Claude AI Rules
|
||||
|
||||
## 🚨 Critical Guidelines
|
||||
## Rules
|
||||
|
||||
**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
|
||||
1. **DO NOT break existing functionality** - All features are critical and must continue to work
|
||||
2. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
|
||||
3. **Always read existing code before making changes** - Understand the current architecture and patterns
|
||||
4. **Follow existing patterns** - Study the codebase style and match it exactly
|
||||
5. **Ask before introducing new patterns** - Use current coding standards or get approval first
|
||||
6. **Prefer editing existing files over creating new ones** - Build on existing work
|
||||
7. **Flag potential gotchas or issues immediately** - Document and report anything unexpected
|
||||
8. **If unsure about an approach, ask before implementing**
|
||||
|
||||
## 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, µTorrent and rTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients (qBittorrent, Transmission, Deluge, uTorrent, rTorrent). 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
|
||||
- Automatic search triggering after removal (Seeker)
|
||||
- Missing and upgrade search
|
||||
- Orphaned download cleanup with cross-seed support
|
||||
- Support for multiple notification providers (Discord, etc.)
|
||||
- Authentication (OIDC, 2FA)
|
||||
- Notification providers (Apprise, Discord, Gotify, Notifiarr, Ntfy, Pushover, Telegram)
|
||||
|
||||
## Architecture & Tech Stack
|
||||
|
||||
### Backend
|
||||
- **.NET 10.0** (C#) with ASP.NET Core
|
||||
- **Architecture**: Clean Architecture pattern
|
||||
- `Cleanuparr.Domain` - Domain models and business logic
|
||||
- **Architecture**: Clean Architecture with `Features/` subdirectory pattern
|
||||
- `Cleanuparr.Api` - REST API and web host (`Features/` for endpoint groups)
|
||||
- `Cleanuparr.Application` - Application services and use cases
|
||||
- `Cleanuparr.Infrastructure` - External integrations (*arr apps, download clients)
|
||||
- `Cleanuparr.Domain` - Domain models (Entities, Enums, Exceptions)
|
||||
- `Cleanuparr.Infrastructure` - External integrations (`Features/` for Arr, DownloadClient, Notifications, etc.)
|
||||
- `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`
|
||||
- **Database**: SQLite with Entity Framework Core
|
||||
- Three separate contexts: `DataContext`, `EventsContext`, `UsersContext`
|
||||
- **Key Libraries**:
|
||||
- MassTransit (messaging)
|
||||
- Quartz.NET (scheduling)
|
||||
- Serilog (logging)
|
||||
- SignalR (real-time communication)
|
||||
- **Testing**: xUnit + NSubstitute + Shouldly
|
||||
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
|
||||
|
||||
### Frontend
|
||||
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
|
||||
- **UI**: Custom glassmorphism design system (no external UI frameworks)
|
||||
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
|
||||
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
|
||||
- **Design System**: 3-layer SCSS (`_variables` → `_tokens` → `_themes`), dark/light themes
|
||||
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
|
||||
- **State Management**: @ngrx/signals (Angular signals-based)
|
||||
- **Real-time Updates**: SignalR (@microsoft/signalr)
|
||||
- **Real-time Updates**: @microsoft/signalr 10.0.0
|
||||
- **PWA**: Service Worker support enabled
|
||||
|
||||
### Documentation
|
||||
- **Docusaurus** (TypeScript-based static site)
|
||||
- Hosted at https://cleanuparr.github.io/Cleanuparr/
|
||||
## Project Structure
|
||||
|
||||
### 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
|
||||
```
|
||||
Cleanuparr/
|
||||
├── code/
|
||||
│ ├── backend/
|
||||
│ │ ├── Cleanuparr.Api/ # REST API (Features/ for endpoint groups)
|
||||
│ │ ├── Cleanuparr.Api.Tests/ # API layer tests
|
||||
│ │ ├── Cleanuparr.Application/ # Business logic layer
|
||||
│ │ ├── Cleanuparr.Domain/ # Domain models
|
||||
│ │ ├── Cleanuparr.Infrastructure/ # External integrations (Features/ subdirs)
|
||||
│ │ ├── Cleanuparr.Infrastructure.Tests/
|
||||
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
|
||||
│ │ ├── Cleanuparr.Persistence.Tests/
|
||||
│ │ └── Cleanuparr.Shared/ # Shared utilities
|
||||
│ ├── frontend/ # Angular 21 application
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ ├── Dockerfile # Multi-stage Docker build
|
||||
│ ├── entrypoint.sh # Docker entrypoint
|
||||
│ └── Makefile # Build & migration helpers
|
||||
├── docs/ # Docusaurus documentation
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── blacklist # Default malware patterns (strict)
|
||||
├── blacklist_permissive # Less strict malware patterns
|
||||
├── whitelist # Safe file extensions
|
||||
└── whitelist_with_subtitles # Includes subtitle formats
|
||||
```
|
||||
|
||||
You need a GitHub PAT with `read:packages` permission.
|
||||
## 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
|
||||
- Use nullable reference types (`<Nullable>enable</Nullable>`)
|
||||
- Add XML documentation comments for public APIs
|
||||
- Use meaningful names - avoid abbreviations unless widely understood
|
||||
- Keep services focused - single responsibility principle
|
||||
- New integrations go under `Features/` subdirectories (e.g., `Infrastructure/Features/Arr/`)
|
||||
|
||||
### Frontend (TypeScript/Angular)
|
||||
- All components must be **standalone** 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`)
|
||||
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
|
||||
- Component naming: `{feature}.component.ts`
|
||||
- Service naming: `{feature}.service.ts`
|
||||
- **Look at similar existing components before creating new ones**
|
||||
|
||||
### Testing
|
||||
- **Backend**: xUnit + NSubstitute + Shouldly
|
||||
- Always use **NSubstitute** for mocking (Moq is being phased out)
|
||||
- Write unit tests for new features and bug fixes
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- No frontend unit tests currently
|
||||
|
||||
### 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"
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Running the Backend
|
||||
```bash
|
||||
@@ -101,250 +140,50 @@ 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:
|
||||
Three separate database contexts, all commands run from the `code` directory:
|
||||
|
||||
```bash
|
||||
# Data migrations
|
||||
# Data migrations (DataContext)
|
||||
make migrate-data name=YourMigrationName
|
||||
|
||||
# Events migrations
|
||||
# Events migrations (EventsContext)
|
||||
make migrate-events name=YourMigrationName
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
make migrate-data name=AddDownloadClientConfig
|
||||
make migrate-events name=AddStrikeEvents
|
||||
# Users migrations (UsersContext)
|
||||
make migrate-users name=YourMigrationName
|
||||
```
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Adding a New *arr Application Integration
|
||||
1. Add integration in `Cleanuparr.Infrastructure/Arr/`
|
||||
1. Add integration in `Cleanuparr.Infrastructure/Features/Arr/`
|
||||
2. Update domain models in `Cleanuparr.Domain/`
|
||||
3. Create/update services in `Cleanuparr.Application/`
|
||||
4. Add API endpoints in `Cleanuparr.Api/`
|
||||
4. Add API endpoints in `Cleanuparr.Api/Features/Arr/`
|
||||
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/`
|
||||
1. Add client implementation in `Cleanuparr.Infrastructure/Features/DownloadClient/`
|
||||
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/`
|
||||
1. Add provider in `Cleanuparr.Infrastructure/Features/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
|
||||
## Key Gotchas
|
||||
|
||||
- **Custom glassmorphism design system** - Do not introduce external UI frameworks (no PrimeNG, Material, Tailwind)
|
||||
- **All frontend components** must be standalone with OnPush change detection
|
||||
- **Database migrations** require awareness of all three contexts (Data, Events, Users)
|
||||
- **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
|
||||
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 21)
|
||||
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
|
||||
- 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
|
||||
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
|
||||
@@ -23,6 +23,9 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
|
||||
> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
|
||||
> - Search for **custom format score upgrades** with automatic score tracking.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Notify on strike or download removal.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
@@ -83,14 +86,14 @@ public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
|
||||
{
|
||||
// Trigger lockout by making several failed login attempts
|
||||
for (var i = 0; i < 5; i++)
|
||||
// Set lockout state directly in the database to avoid timing sensitivity
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 5;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddMinutes(5);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
@@ -103,6 +106,16 @@ public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Reset lockout for subsequent tests
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class SeedingRulesControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly SeedingRulesController _controller;
|
||||
|
||||
public SeedingRulesControllerTests()
|
||||
{
|
||||
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<SeedingRulesController>>();
|
||||
_controller = new SeedingRulesController(logger, _dataContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static SeedingRuleRequest CreateValidRequest(
|
||||
string name = "Test Rule",
|
||||
List<string>? categories = null,
|
||||
List<string>? trackerPatterns = null,
|
||||
List<string>? tagsAny = null,
|
||||
List<string>? tagsAll = null,
|
||||
int? priority = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
bool deleteSourceFiles = true)
|
||||
{
|
||||
return new SeedingRuleRequest
|
||||
{
|
||||
Name = name,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = trackerPatterns ?? [],
|
||||
TagsAny = tagsAny ?? [],
|
||||
TagsAll = tagsAll ?? [],
|
||||
Priority = priority,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
DeleteSourceFiles = deleteSourceFiles,
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement GetJsonBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
private static JsonElement GetCreatedJsonBody(IActionResult result)
|
||||
{
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var json = JsonSerializer.Serialize(createdResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GetSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_EmptyRules_ReturnsEmptyList()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var array = JsonDocument.Parse(json).RootElement;
|
||||
array.GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_ReturnsRulesOrderedByPriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule C", priority: 3);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule B", priority: 2);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var array = JsonDocument.Parse(json).RootElement;
|
||||
array.GetArrayLength().ShouldBe(3);
|
||||
array[0].GetProperty("name").GetString().ShouldBe("Rule A");
|
||||
array[1].GetProperty("name").GetString().ShouldBe("Rule B");
|
||||
array[2].GetProperty("name").GetString().ShouldBe("Rule C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.GetSeedingRules(Guid.NewGuid());
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_QBitClient_ReturnsTagFields()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id,
|
||||
tagsAny: ["hd", "private"], tagsAll: ["required"]);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var rule = JsonDocument.Parse(json).RootElement[0];
|
||||
rule.GetProperty("tagsAny").GetArrayLength().ShouldBe(2);
|
||||
rule.GetProperty("tagsAll").GetArrayLength().ShouldBe(1);
|
||||
rule.GetProperty("tagsAll")[0].GetString().ShouldBe("required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_DelugeClient_ReturnsEmptyTagFields()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.Deluge, "Test Deluge");
|
||||
SeedingRulesTestDataFactory.AddDelugeSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var rule = JsonDocument.Parse(json).RootElement[0];
|
||||
rule.GetProperty("tagsAny").GetArrayLength().ShouldBe(0);
|
||||
rule.GetProperty("tagsAll").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// CreateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ValidRequest_ReturnsCreated()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(name: "Movies Rule", categories: ["movies", "films"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
createdResult.StatusCode.ShouldBe(201);
|
||||
|
||||
var body = GetCreatedJsonBody(result);
|
||||
body.GetProperty("Name").GetString().ShouldBe("Movies Rule");
|
||||
body.GetProperty("Categories").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_AutoAssignsPriority_WhenNotProvided()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var body = GetCreatedJsonBody(result);
|
||||
body.GetProperty("Priority").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_AutoAssignsSequentialPriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
|
||||
|
||||
var request = CreateValidRequest(name: "Second Rule", categories: ["tv"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var body = GetCreatedJsonBody(result);
|
||||
body.GetProperty("Priority").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_DuplicatePriority_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
|
||||
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_EmptyCategories_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(categories: []);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
// Validate() throws ValidationException → caught → BadRequest
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SanitizesWhitespaceInLists()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(
|
||||
trackerPatterns: ["", " ", "valid.com", " trimmed.com "]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var body = GetCreatedJsonBody(result);
|
||||
var patterns = body.GetProperty("TrackerPatterns");
|
||||
patterns.GetArrayLength().ShouldBe(2);
|
||||
patterns[0].GetString().ShouldBe("valid.com");
|
||||
patterns[1].GetString().ShouldBe("trimmed.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ForTransmission_CreatesTransmissionRule()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext,
|
||||
DownloadClientTypeName.Transmission, "Test Transmission");
|
||||
var request = CreateValidRequest(tagsAny: ["tag1"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
createdResult.Value.ShouldBeOfType<TransmissionSeedingRule>();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// UpdateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_ValidRequest_ReturnsOk()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(name: "Updated Name", categories: ["tv", "anime"]);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.Name.ShouldBe("Updated Name");
|
||||
updated.Categories.ShouldBe(new List<string> { "tv", "anime" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_DoesNotChangePriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 5);
|
||||
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.Priority.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesTagsForTagFilterableClient()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(tagsAny: ["new-tag"], tagsAll: ["must-have"]);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.TagsAny.ShouldBe(new List<string> { "new-tag" });
|
||||
updated.TagsAll.ShouldBe(new List<string> { "must-have" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_ValidationFailure_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
// Both maxRatio and maxSeedTime negative → validation failure
|
||||
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ReorderSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_ValidRequest_ReturnsNoContent()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule2.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_AssignsSequentialPriorities()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
var rule3 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "C", priority: 3);
|
||||
|
||||
// Reverse order
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule3.Id, rule2.Id, rule1.Id] };
|
||||
await _controller.ReorderSeedingRules(client.Id, request);
|
||||
|
||||
// Verify via GET
|
||||
var getResult = await _controller.GetSeedingRules(client.Id);
|
||||
var okResult = getResult.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var array = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
array[0].GetProperty("name").GetString().ShouldBe("C");
|
||||
array[0].GetProperty("priority").GetInt32().ShouldBe(1);
|
||||
array[1].GetProperty("name").GetString().ShouldBe("B");
|
||||
array[1].GetProperty("priority").GetInt32().ShouldBe(2);
|
||||
array[2].GetProperty("name").GetString().ShouldBe("A");
|
||||
array[2].GetProperty("priority").GetInt32().ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_DuplicateIds_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_WrongCount_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
// Only send 1 of 2 IDs
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_UnknownRuleId_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// DeleteSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_ExistingRule_ReturnsNoContent()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var result = await _controller.DeleteSeedingRule(rule.Id);
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_VerifiesRuleRemoved()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
await _controller.DeleteSeedingRule(rule.Id);
|
||||
|
||||
// Verify rule no longer exists
|
||||
var getResult = await _controller.GetSeedingRules(client.Id);
|
||||
var okResult = getResult.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var array = JsonDocument.Parse(json).RootElement;
|
||||
array.GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for SeedingRulesController tests
|
||||
/// </summary>
|
||||
public static class SeedingRulesTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static DownloadClientConfig AddDownloadClient(
|
||||
DataContext context,
|
||||
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
|
||||
string name = "Test qBittorrent")
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin"
|
||||
};
|
||||
|
||||
context.DownloadClients.Add(config);
|
||||
context.SaveChanges();
|
||||
return config;
|
||||
}
|
||||
|
||||
public static QBitSeedingRule AddQBitSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
List<string>? trackerPatterns = null,
|
||||
List<string>? tagsAny = null,
|
||||
List<string>? tagsAll = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1)
|
||||
{
|
||||
var rule = new QBitSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = trackerPatterns ?? [],
|
||||
TagsAny = tagsAny ?? [],
|
||||
TagsAll = tagsAll ?? [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.QBitSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
|
||||
public static DelugeSeedingRule AddDelugeSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1)
|
||||
{
|
||||
var rule = new DelugeSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.DelugeSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
|
||||
public static TransmissionSeedingRule AddTransmissionSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1)
|
||||
{
|
||||
var rule = new TransmissionSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = [],
|
||||
TagsAny = [],
|
||||
TagsAll = [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.TransmissionSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class CustomFormatScoreControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly CustomFormatScoreController _controller;
|
||||
|
||||
public CustomFormatScoreControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_controller = new CustomFormatScoreController(_dataContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetCustomFormatScores Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("PageSize").GetInt32().ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(hideMet: true);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideUnmonitoredTrue_ExcludesUnmonitoredItems()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Monitored Movie", currentScore: 100, cutoffScore: 500, isMonitored: true);
|
||||
AddScoreEntry(radarr.Id, 2, "Unmonitored Movie", currentScore: 200, cutoffScore: 500, isMonitored: false);
|
||||
AddScoreEntry(radarr.Id, 3, "Another Monitored", currentScore: 300, cutoffScore: 500, isMonitored: true);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(hideUnmonitored: true);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
items[0].GetProperty("Title").GetString().ShouldBe("Another Monitored");
|
||||
items[1].GetProperty("Title").GetString().ShouldBe("Monitored Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-2));
|
||||
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(sortBy: "date");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
|
||||
body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
|
||||
itemType: InstanceType.Sonarr);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
for (int i = 1; i <= 7; i++)
|
||||
{
|
||||
AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
|
||||
}
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRecentUpgrades Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
|
||||
{
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
var upgrade = body.GetProperty("Items")[0];
|
||||
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
|
||||
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// 100 -> 200 -> 300 = two upgrades for the same item
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Old upgrade (outside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
// Recent upgrade (inside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(days: 7);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Item 1: upgrade happened 5 days ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
|
||||
// Item 2: upgrade happened 1 day ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
// Most recent upgrade (item 2) should be first
|
||||
items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
|
||||
items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStats Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_WithNoEntries_ReturnsZeroes()
|
||||
{
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(0);
|
||||
stats.BelowCutoff.ShouldBe(0);
|
||||
stats.AtOrAboveCutoff.ShouldBe(0);
|
||||
stats.RecentUpgrades.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(3);
|
||||
stats.BelowCutoff.ShouldBe(1);
|
||||
stats.AtOrAboveCutoff.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CountsRecentUpgradesFromLast7Days()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
// Upgrade within 7 days
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
// Upgrade outside 7 days (should not be counted)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.RecentUpgrades.ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddScoreEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
string title,
|
||||
int currentScore,
|
||||
int cutoffScore,
|
||||
InstanceType itemType = InstanceType.Radarr,
|
||||
DateTime? lastSynced = null,
|
||||
bool isMonitored = true)
|
||||
{
|
||||
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = 0,
|
||||
ItemType = itemType,
|
||||
Title = title,
|
||||
FileId = externalItemId * 10,
|
||||
CurrentScore = currentScore,
|
||||
CutoffScore = cutoffScore,
|
||||
QualityProfileName = "HD",
|
||||
IsMonitored = isMonitored,
|
||||
LastSyncedAt = lastSynced ?? DateTime.UtcNow
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
int score,
|
||||
DateTime recordedAt,
|
||||
long episodeId = 0,
|
||||
int cutoffScore = 500,
|
||||
InstanceType itemType = InstanceType.Radarr)
|
||||
{
|
||||
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = episodeId,
|
||||
ItemType = itemType,
|
||||
Title = $"Item {externalItemId}",
|
||||
Score = score,
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = recordedAt
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SearchStatsControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly SearchStatsController _controller;
|
||||
|
||||
public SearchStatsControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new SearchStatsController(_dataContext, _eventsContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetEvents with SearchEventData
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithNoSearchEventData_ReturnsUnknownDefaults()
|
||||
{
|
||||
AddSearchEvent();
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchEventData_ReturnsAllFields()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(
|
||||
arrInstanceId: radarr.Id,
|
||||
itemTitle: "Movie A",
|
||||
searchType: SeekerSearchType.Proactive,
|
||||
searchReason: SeekerSearchReason.Missing,
|
||||
grabbedItems: ["Movie A (2024)"]);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
item.GetProperty("InstanceType").GetString().ShouldBe(nameof(InstanceType.Radarr));
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Movie A");
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Missing));
|
||||
item.GetProperty("GrabbedItems")[0].GetString().ShouldBe("Movie A (2024)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithReplacementSearchType_ParsesCorrectEnum()
|
||||
{
|
||||
AddSearchEvent(
|
||||
itemTitle: "Series A",
|
||||
searchType: SeekerSearchType.Replacement,
|
||||
searchReason: SeekerSearchReason.Replacement);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Replacement));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetEvents Filtering
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithInstanceIdFilter_FiltersByArrInstanceId()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(arrInstanceId: radarr.Id, itemTitle: "Radarr Movie");
|
||||
AddSearchEvent(arrInstanceId: sonarr.Id, itemTitle: "Sonarr Series");
|
||||
|
||||
var result = await _controller.GetEvents(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
|
||||
{
|
||||
var cycleA = Guid.NewGuid();
|
||||
var cycleB = Guid.NewGuid();
|
||||
|
||||
AddSearchEvent(cycleId: cycleA, itemTitle: "Cycle A Movie");
|
||||
AddSearchEvent(cycleId: cycleB, itemTitle: "Cycle B Movie");
|
||||
|
||||
var result = await _controller.GetEvents(cycleId: cycleA);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cycle A Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchFilter_FiltersOnItemTitle()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "The Matrix");
|
||||
AddSearchEvent(itemTitle: "Breaking Bad");
|
||||
|
||||
var result = await _controller.GetEvents(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
AddSearchEvent(itemTitle: $"Event {i}");
|
||||
}
|
||||
|
||||
var result = await _controller.GetEvents(page: 2, pageSize: 2);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3);
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(2);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddSearchEvent(
|
||||
string? itemTitle = null,
|
||||
SeekerSearchType searchType = SeekerSearchType.Proactive,
|
||||
SeekerSearchReason searchReason = SeekerSearchReason.Missing,
|
||||
List<string>? grabbedItems = null,
|
||||
Guid? arrInstanceId = null,
|
||||
Guid? cycleId = null,
|
||||
SearchCommandStatus? searchStatus = null)
|
||||
{
|
||||
var appEvent = new AppEvent
|
||||
{
|
||||
EventType = EventType.SearchTriggered,
|
||||
Message = "Search triggered",
|
||||
Severity = EventSeverity.Information,
|
||||
ArrInstanceId = arrInstanceId,
|
||||
CycleId = cycleId,
|
||||
SearchStatus = searchStatus,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_eventsContext.Events.Add(appEvent);
|
||||
_eventsContext.SaveChanges();
|
||||
|
||||
if (itemTitle is not null)
|
||||
{
|
||||
_eventsContext.SearchEventData.Add(new SearchEventData
|
||||
{
|
||||
AppEventId = appEvent.Id,
|
||||
ItemTitle = itemTitle,
|
||||
SearchType = searchType,
|
||||
SearchReason = searchReason,
|
||||
GrabbedItems = grabbedItems ?? [],
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SeekerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly ILogger<SeekerConfigController> _logger;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly SeekerConfigController _controller;
|
||||
|
||||
public SeekerConfigControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_logger = Substitute.For<ILogger<SeekerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region GetSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
var instance = response.Instances.ShouldHaveSingleItem();
|
||||
instance.ArrInstanceId.ShouldBe(radarr.Id);
|
||||
instance.Enabled.ShouldBeFalse();
|
||||
instance.SkipTags.ShouldBeEmpty();
|
||||
instance.ActiveDownloadLimit.ShouldBe(3);
|
||||
instance.MinCycleTimeDays.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.Count.ShouldBe(2);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
|
||||
response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false // No instances enabled
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeekerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Default interval is 3, change to 5
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.Seeker, null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Keep interval at default (3)
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.StartJob(Arg.Any<JobType>(), null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// UseCustomFormatScore was false (default), now enable it on the instance
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// First enable CF score on the instance
|
||||
var instanceConfig = await _dataContext.SeekerInstanceConfigs
|
||||
.FirstAsync(s => s.ArrInstanceId == radarr.Id);
|
||||
instanceConfig.UseCustomFormatScore = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Now disable it
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = false }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenSearchReenabledWithCustomFormatActive_TriggersSyncerOnce()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Set up state: CF score already enabled on instance, search currently disabled
|
||||
var instanceConfig = await _dataContext.SeekerInstanceConfigs
|
||||
.FirstAsync(s => s.ArrInstanceId == radarr.Id);
|
||||
instanceConfig.UseCustomFormatScore = true;
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = false;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Re-enable search
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = false,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
// Radarr already has a config
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false,
|
||||
SkipTags = ["old-tag"],
|
||||
ActiveDownloadLimit = 2,
|
||||
MinCycleTimeDays = 5
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
// Update existing radarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["new-tag"],
|
||||
ActiveDownloadLimit = 5,
|
||||
MinCycleTimeDays = 14
|
||||
},
|
||||
// Create new sonarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = sonarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["sonarr-tag"],
|
||||
ActiveDownloadLimit = 3,
|
||||
MinCycleTimeDays = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
|
||||
configs.Count.ShouldBe(2);
|
||||
|
||||
var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
|
||||
radarrConfig.Enabled.ShouldBeTrue();
|
||||
radarrConfig.SkipTags.ShouldContain("new-tag");
|
||||
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
|
||||
radarrConfig.MinCycleTimeDays.ShouldBe(14);
|
||||
|
||||
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
|
||||
sonarrConfig.Enabled.ShouldBeTrue();
|
||||
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for Seeker controller tests
|
||||
/// </summary>
|
||||
public static class SeekerTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
public static EventsContext CreateEventsContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new EventsContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Radarr",
|
||||
Url = new Uri("http://radarr:7878"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Lidarr",
|
||||
Url = new Uri("http://lidarr:8686"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,6 @@ public class EventsController : ControllerBase
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, 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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,8 +57,13 @@ public class JobsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/start")]
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null)
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job cannot be manually controlled");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get the schedule from the request body if provided
|
||||
@@ -82,6 +87,11 @@ public class JobsController : ControllerBase
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job cannot be manually triggered");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
@@ -102,6 +112,11 @@ public class JobsController : ControllerBase
|
||||
[HttpPut("{jobType}/schedule")]
|
||||
public async Task<IActionResult> UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job schedule cannot be manually modified");
|
||||
}
|
||||
|
||||
if (scheduleRequest?.Schedule == null)
|
||||
{
|
||||
return BadRequest("Schedule is required");
|
||||
|
||||
@@ -68,9 +68,7 @@ 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.InstanceUrl, pattern) ||
|
||||
EF.Functions.Like(e.DownloadClientName, pattern)
|
||||
EF.Functions.Like(e.Data, pattern)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -30,9 +28,6 @@ public static class MainDI
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
|
||||
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
@@ -60,14 +55,6 @@ public static class MainDI
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-hunter-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("notification-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
||||
|
||||
@@ -5,8 +5,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
@@ -49,8 +47,9 @@ public static class ServicesDI
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<Seeker>()
|
||||
.AddScoped<CustomFormatScoreSyncer>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
|
||||
@@ -59,13 +58,15 @@ public static class ServicesDI
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IRuleManager, RuleManager>()
|
||||
.AddScoped<IRuleEvaluator, RuleEvaluator>()
|
||||
.AddScoped<IQueueRuleManager, QueueRuleManager>()
|
||||
.AddScoped<IQueueRuleEvaluator, QueueRuleEvaluator>()
|
||||
.AddScoped<ISeedingRuleEvaluator, SeedingRuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddScoped<IStatsService, StatsService>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>();
|
||||
.AddHostedService<AppStatusRefreshService>()
|
||||
.AddHostedService<SeekerCommandMonitor>();
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public record ReorderSeedingRulesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// IDs of seeding rules in the desired priority order (first = highest priority).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public List<Guid> OrderedIds { get; init; } = [];
|
||||
}
|
||||
+32
-2
@@ -1,4 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
@@ -8,6 +8,36 @@ public record SeedingRuleRequest
|
||||
[Required]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Categories this rule applies to. At least one must be specified.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1, ErrorMessage = "At least one category must be specified.")]
|
||||
public List<string> Categories { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Tracker domain suffixes to match (e.g. "tracker.example.com"). Empty = any tracker.
|
||||
/// </summary>
|
||||
public List<string> TrackerPatterns { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Torrent must have at least one of these tags/labels. Accepted for all clients;
|
||||
/// silently ignored for Deluge, rTorrent, and µTorrent.
|
||||
/// </summary>
|
||||
public List<string> TagsAny { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Torrent must have ALL of these tags/labels. Accepted for all clients;
|
||||
/// silently ignored for Deluge, rTorrent, and µTorrent.
|
||||
/// </summary>
|
||||
public List<string> TagsAll { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation priority (lower = evaluated first). Auto-assigned if not provided.
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue, ErrorMessage = "Priority must be a positive integer.")]
|
||||
public int? Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which torrent privacy types this rule applies to.
|
||||
/// </summary>
|
||||
@@ -32,4 +62,4 @@ public record SeedingRuleRequest
|
||||
/// Whether to delete the source files when cleaning the download.
|
||||
/// </summary>
|
||||
public bool DeleteSourceFiles { get; init; } = true;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record UnlinkedConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string TargetCategory { get; init; } = "cleanuparr-unlinked";
|
||||
|
||||
public bool UseTag { get; init; }
|
||||
|
||||
public List<string> IgnoredRootDirs { get; init; } = [];
|
||||
|
||||
public List<string> Categories { get; init; } = [];
|
||||
|
||||
public string? DownloadDirectorySource { get; init; }
|
||||
|
||||
public string? DownloadDirectoryTarget { get; init; }
|
||||
}
|
||||
-15
@@ -11,20 +11,5 @@ public sealed record UpdateDownloadCleanerConfigRequest
|
||||
/// </summary>
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public List<SeedingRuleRequest> Categories { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether unlinked download handling is enabled.
|
||||
/// </summary>
|
||||
public bool UnlinkedEnabled { get; init; }
|
||||
|
||||
public string UnlinkedTargetCategory { get; init; } = "cleanuparr-unlinked";
|
||||
|
||||
public bool UnlinkedUseTag { get; init; }
|
||||
|
||||
public List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
|
||||
|
||||
public List<string> UnlinkedCategories { get; init; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
}
|
||||
+66
-33
@@ -1,6 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
@@ -42,10 +40,72 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.DownloadCleanerConfigs
|
||||
.Include(x => x.Categories)
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
|
||||
var downloadClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var allQBitRules = await _dataContext.QBitSeedingRules.AsNoTracking().ToListAsync();
|
||||
var allDelugeRules = await _dataContext.DelugeSeedingRules.AsNoTracking().ToListAsync();
|
||||
var allTransmissionRules = await _dataContext.TransmissionSeedingRules.AsNoTracking().ToListAsync();
|
||||
var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync();
|
||||
var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync();
|
||||
var allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
|
||||
|
||||
var clients = new List<object>();
|
||||
|
||||
foreach (var client in downloadClients)
|
||||
{
|
||||
var seedingRules = SeedingRuleHelper.FilterForClient(
|
||||
client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
|
||||
var unlinkedConfig = allUnlinkedConfigs.FirstOrDefault(u => u.DownloadClientConfigId == client.Id);
|
||||
|
||||
clients.Add(new
|
||||
{
|
||||
downloadClientId = client.Id,
|
||||
downloadClientName = client.Name,
|
||||
downloadClientEnabled = client.Enabled,
|
||||
downloadClientTypeName = client.TypeName,
|
||||
seedingRules = seedingRules.Select(r => new
|
||||
{
|
||||
id = r.Id,
|
||||
name = r.Name,
|
||||
categories = r.Categories,
|
||||
trackerPatterns = r.TrackerPatterns,
|
||||
tagsAny = (r as ITagFilterable)?.TagsAny ?? new List<string>(),
|
||||
tagsAll = (r as ITagFilterable)?.TagsAll ?? new List<string>(),
|
||||
priority = r.Priority,
|
||||
privacyType = r.PrivacyType,
|
||||
maxRatio = r.MaxRatio,
|
||||
minSeedTime = r.MinSeedTime,
|
||||
maxSeedTime = r.MaxSeedTime,
|
||||
deleteSourceFiles = r.DeleteSourceFiles,
|
||||
}),
|
||||
unlinkedConfig = unlinkedConfig is not null
|
||||
? new
|
||||
{
|
||||
enabled = unlinkedConfig.Enabled,
|
||||
targetCategory = unlinkedConfig.TargetCategory,
|
||||
useTag = unlinkedConfig.UseTag,
|
||||
ignoredRootDirs = unlinkedConfig.IgnoredRootDirs,
|
||||
categories = unlinkedConfig.Categories,
|
||||
downloadDirectorySource = unlinkedConfig.DownloadDirectorySource,
|
||||
downloadDirectoryTarget = unlinkedConfig.DownloadDirectoryTarget,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
config.Enabled,
|
||||
config.CronExpression,
|
||||
config.UseAdvancedScheduling,
|
||||
config.IgnoredDownloads,
|
||||
clients,
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -70,40 +130,13 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
// Get existing configuration
|
||||
var oldConfig = await _dataContext.DownloadCleanerConfigs
|
||||
.Include(x => x.Categories)
|
||||
.FirstAsync();
|
||||
// Update global config only
|
||||
var oldConfig = await _dataContext.DownloadCleanerConfigs.FirstAsync();
|
||||
|
||||
oldConfig.Enabled = newConfigDto.Enabled;
|
||||
oldConfig.CronExpression = newConfigDto.CronExpression;
|
||||
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
|
||||
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
|
||||
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
|
||||
oldConfig.UnlinkedIgnoredRootDirs = newConfigDto.UnlinkedIgnoredRootDirs;
|
||||
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
oldConfig.Categories.Clear();
|
||||
|
||||
_dataContext.SeedingRules.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
|
||||
|
||||
foreach (var categoryDto in newConfigDto.Categories)
|
||||
{
|
||||
_dataContext.SeedingRules.Add(new SeedingRule
|
||||
{
|
||||
Name = categoryDto.Name,
|
||||
PrivacyType = categoryDto.PrivacyType,
|
||||
MaxRatio = categoryDto.MaxRatio,
|
||||
MinSeedTime = categoryDto.MinSeedTime,
|
||||
MaxSeedTime = categoryDto.MaxSeedTime,
|
||||
DeleteSourceFiles = categoryDto.DeleteSourceFiles,
|
||||
DownloadCleanerConfigId = oldConfig.Id
|
||||
});
|
||||
}
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/seeding-rules")]
|
||||
[Authorize]
|
||||
public class SeedingRulesController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<SeedingRulesController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public SeedingRulesController(
|
||||
ILogger<SeedingRulesController> logger,
|
||||
DataContext dataContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
[HttpGet("{downloadClientId}")]
|
||||
public async Task<IActionResult> GetSeedingRules(Guid downloadClientId)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var client = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
|
||||
}
|
||||
|
||||
var rules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
|
||||
|
||||
return Ok(rules.Select(r => new
|
||||
{
|
||||
id = r.Id,
|
||||
name = r.Name,
|
||||
categories = r.Categories,
|
||||
trackerPatterns = r.TrackerPatterns,
|
||||
tagsAny = (r as ITagFilterable)?.TagsAny ?? new List<string>(),
|
||||
tagsAll = (r as ITagFilterable)?.TagsAll ?? new List<string>(),
|
||||
priority = r.Priority,
|
||||
privacyType = r.PrivacyType,
|
||||
maxRatio = r.MaxRatio,
|
||||
minSeedTime = r.MinSeedTime,
|
||||
maxSeedTime = r.MaxSeedTime,
|
||||
deleteSourceFiles = r.DeleteSourceFiles,
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve seeding rules for client {ClientId}", downloadClientId);
|
||||
return StatusCode(500, new { Message = "Failed to retrieve seeding rules", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{downloadClientId}")]
|
||||
public async Task<IActionResult> CreateSeedingRule(Guid downloadClientId, [FromBody] SeedingRuleRequest ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var client = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
|
||||
}
|
||||
|
||||
var existingRules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
|
||||
|
||||
if (ruleDto.Priority.HasValue && existingRules.Any(r => r.Priority == ruleDto.Priority.Value))
|
||||
{
|
||||
return BadRequest(new { Message = $"A seeding rule with priority {ruleDto.Priority.Value} already exists for this client" });
|
||||
}
|
||||
|
||||
int priority = ruleDto.Priority ?? (existingRules.Count == 0 ? 1 : existingRules.Max(r => r.Priority) + 1);
|
||||
|
||||
var rule = CreateRule(client.TypeName, client.Id, ruleDto, priority);
|
||||
rule.Validate();
|
||||
|
||||
AddRuleToDbSet(rule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created seeding rule: {RuleName} with ID: {RuleId} for client {ClientId}",
|
||||
rule.Name, rule.Id, downloadClientId);
|
||||
|
||||
return CreatedAtAction(nameof(GetSeedingRules), new { downloadClientId }, rule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for seeding rule creation: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create seeding rule: {RuleName} for client {ClientId}",
|
||||
ruleDto.Name, downloadClientId);
|
||||
return StatusCode(500, new { Message = "Failed to create seeding rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateSeedingRule(Guid id, [FromBody] SeedingRuleRequest ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var (existingRule, _) = await SeedingRuleHelper.FindByIdAsync(_dataContext, id);
|
||||
|
||||
if (existingRule is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
existingRule.Name = ruleDto.Name.Trim();
|
||||
existingRule.Categories = SanitizeStringList(ruleDto.Categories);
|
||||
existingRule.TrackerPatterns = SanitizeStringList(ruleDto.TrackerPatterns);
|
||||
existingRule.PrivacyType = ruleDto.PrivacyType;
|
||||
existingRule.MaxRatio = ruleDto.MaxRatio;
|
||||
existingRule.MinSeedTime = ruleDto.MinSeedTime;
|
||||
existingRule.MaxSeedTime = ruleDto.MaxSeedTime;
|
||||
existingRule.DeleteSourceFiles = ruleDto.DeleteSourceFiles;
|
||||
// Priority is intentionally NOT updated here — use the reorder endpoint
|
||||
|
||||
if (existingRule is ITagFilterable tagFilterable)
|
||||
{
|
||||
tagFilterable.TagsAny = SanitizeStringList(ruleDto.TagsAny);
|
||||
tagFilterable.TagsAll = SanitizeStringList(ruleDto.TagsAll);
|
||||
}
|
||||
|
||||
existingRule.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated seeding rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
|
||||
|
||||
return Ok(existingRule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for seeding rule update: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update seeding rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to update seeding rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{downloadClientId}/reorder")]
|
||||
public async Task<IActionResult> ReorderSeedingRules(Guid downloadClientId, [FromBody] ReorderSeedingRulesRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var client = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
|
||||
}
|
||||
|
||||
List<ISeedingRule> rules = await SeedingRuleHelper.GetForClientTrackedAsync(_dataContext, client);
|
||||
|
||||
if (request.OrderedIds.Distinct().Count() != request.OrderedIds.Count)
|
||||
{
|
||||
return BadRequest(new { Message = "Duplicate rule IDs are not allowed" });
|
||||
}
|
||||
|
||||
if (request.OrderedIds.Count != rules.Count)
|
||||
{
|
||||
return BadRequest(new { Message = $"Expected {rules.Count} rule IDs but received {request.OrderedIds.Count}. All rules must be included." });
|
||||
}
|
||||
|
||||
foreach (Guid id in request.OrderedIds.Where(id => rules.All(r => r.Id != id)))
|
||||
{
|
||||
return BadRequest(new { Message = $"Rule with ID {id} not found for client {downloadClientId}" });
|
||||
}
|
||||
|
||||
int priority = 1;
|
||||
var lookup = rules.ToDictionary(r => r.Id);
|
||||
|
||||
foreach (var id in request.OrderedIds)
|
||||
{
|
||||
lookup[id].Priority = priority++;
|
||||
}
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Reordered {Count} seeding rules for client {ClientId}", rules.Count, downloadClientId);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to reorder seeding rules for client {ClientId}", downloadClientId);
|
||||
return StatusCode(500, new { Message = "Failed to reorder seeding rules", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteSeedingRule(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var (existingRule, _) = await SeedingRuleHelper.FindByIdAsync(_dataContext, id);
|
||||
|
||||
if (existingRule is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
RemoveRuleFromDbSet(existingRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Deleted seeding rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete seeding rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to delete seeding rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> SanitizeStringList(List<string> list)
|
||||
=> list.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToList();
|
||||
|
||||
private static ISeedingRule CreateRule(DownloadClientTypeName typeName, Guid clientId, SeedingRuleRequest dto, int priority)
|
||||
{
|
||||
var categories = SanitizeStringList(dto.Categories);
|
||||
var trackerPatterns = SanitizeStringList(dto.TrackerPatterns);
|
||||
var tagsAny = SanitizeStringList(dto.TagsAny);
|
||||
var tagsAll = SanitizeStringList(dto.TagsAll);
|
||||
|
||||
return typeName switch
|
||||
{
|
||||
DownloadClientTypeName.qBittorrent => new QBitSeedingRule
|
||||
{
|
||||
DownloadClientConfigId = clientId,
|
||||
Name = dto.Name.Trim(),
|
||||
Categories = categories,
|
||||
TrackerPatterns = trackerPatterns,
|
||||
TagsAny = tagsAny,
|
||||
TagsAll = tagsAll,
|
||||
Priority = priority,
|
||||
PrivacyType = dto.PrivacyType,
|
||||
MaxRatio = dto.MaxRatio,
|
||||
MinSeedTime = dto.MinSeedTime,
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
},
|
||||
DownloadClientTypeName.Deluge => new DelugeSeedingRule
|
||||
{
|
||||
DownloadClientConfigId = clientId,
|
||||
Name = dto.Name.Trim(),
|
||||
Categories = categories,
|
||||
TrackerPatterns = trackerPatterns,
|
||||
Priority = priority,
|
||||
PrivacyType = dto.PrivacyType,
|
||||
MaxRatio = dto.MaxRatio,
|
||||
MinSeedTime = dto.MinSeedTime,
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
},
|
||||
DownloadClientTypeName.Transmission => new TransmissionSeedingRule
|
||||
{
|
||||
DownloadClientConfigId = clientId,
|
||||
Name = dto.Name.Trim(),
|
||||
Categories = categories,
|
||||
TrackerPatterns = trackerPatterns,
|
||||
TagsAny = tagsAny,
|
||||
TagsAll = tagsAll,
|
||||
Priority = priority,
|
||||
PrivacyType = dto.PrivacyType,
|
||||
MaxRatio = dto.MaxRatio,
|
||||
MinSeedTime = dto.MinSeedTime,
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
},
|
||||
DownloadClientTypeName.uTorrent => new UTorrentSeedingRule
|
||||
{
|
||||
DownloadClientConfigId = clientId,
|
||||
Name = dto.Name.Trim(),
|
||||
Categories = categories,
|
||||
TrackerPatterns = trackerPatterns,
|
||||
Priority = priority,
|
||||
PrivacyType = dto.PrivacyType,
|
||||
MaxRatio = dto.MaxRatio,
|
||||
MinSeedTime = dto.MinSeedTime,
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
},
|
||||
DownloadClientTypeName.rTorrent => new RTorrentSeedingRule
|
||||
{
|
||||
DownloadClientConfigId = clientId,
|
||||
Name = dto.Name.Trim(),
|
||||
Categories = categories,
|
||||
TrackerPatterns = trackerPatterns,
|
||||
Priority = priority,
|
||||
PrivacyType = dto.PrivacyType,
|
||||
MaxRatio = dto.MaxRatio,
|
||||
MinSeedTime = dto.MinSeedTime,
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(typeName), typeName, "Unsupported download client type")
|
||||
};
|
||||
}
|
||||
|
||||
private void AddRuleToDbSet(ISeedingRule rule)
|
||||
{
|
||||
switch (rule)
|
||||
{
|
||||
case QBitSeedingRule qbit:
|
||||
_dataContext.QBitSeedingRules.Add(qbit);
|
||||
break;
|
||||
case DelugeSeedingRule deluge:
|
||||
_dataContext.DelugeSeedingRules.Add(deluge);
|
||||
break;
|
||||
case TransmissionSeedingRule transmission:
|
||||
_dataContext.TransmissionSeedingRules.Add(transmission);
|
||||
break;
|
||||
case UTorrentSeedingRule utorrent:
|
||||
_dataContext.UTorrentSeedingRules.Add(utorrent);
|
||||
break;
|
||||
case RTorrentSeedingRule rtorrent:
|
||||
_dataContext.RTorrentSeedingRules.Add(rtorrent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveRuleFromDbSet(ISeedingRule rule)
|
||||
{
|
||||
switch (rule)
|
||||
{
|
||||
case QBitSeedingRule qbit:
|
||||
_dataContext.QBitSeedingRules.Remove(qbit);
|
||||
break;
|
||||
case DelugeSeedingRule deluge:
|
||||
_dataContext.DelugeSeedingRules.Remove(deluge);
|
||||
break;
|
||||
case TransmissionSeedingRule transmission:
|
||||
_dataContext.TransmissionSeedingRules.Remove(transmission);
|
||||
break;
|
||||
case UTorrentSeedingRule utorrent:
|
||||
_dataContext.UTorrentSeedingRules.Remove(utorrent);
|
||||
break;
|
||||
case RTorrentSeedingRule rtorrent:
|
||||
_dataContext.RTorrentSeedingRules.Remove(rtorrent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/unlinked-config")]
|
||||
[Authorize]
|
||||
public class UnlinkedConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<UnlinkedConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public UnlinkedConfigController(
|
||||
ILogger<UnlinkedConfigController> logger,
|
||||
DataContext dataContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
[HttpGet("{downloadClientId}")]
|
||||
public async Task<IActionResult> GetUnlinkedConfig(Guid downloadClientId)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var client = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
|
||||
}
|
||||
|
||||
var config = await _dataContext.UnlinkedConfigs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadClientId);
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve unlinked config for client {ClientId}", downloadClientId);
|
||||
return StatusCode(500, new { Message = "Failed to retrieve unlinked config", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{downloadClientId}")]
|
||||
public async Task<IActionResult> UpdateUnlinkedConfig(Guid downloadClientId, [FromBody] UnlinkedConfigRequest dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var client = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
|
||||
}
|
||||
|
||||
var existing = await _dataContext.UnlinkedConfigs
|
||||
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadClientId);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new UnlinkedConfig
|
||||
{
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
};
|
||||
_dataContext.UnlinkedConfigs.Add(existing);
|
||||
}
|
||||
|
||||
existing.Enabled = dto.Enabled;
|
||||
existing.TargetCategory = dto.TargetCategory;
|
||||
existing.UseTag = dto.UseTag;
|
||||
existing.IgnoredRootDirs = dto.IgnoredRootDirs;
|
||||
existing.Categories = dto.Categories;
|
||||
existing.DownloadDirectorySource = dto.DownloadDirectorySource;
|
||||
existing.DownloadDirectoryTarget = dto.DownloadDirectoryTarget;
|
||||
|
||||
existing.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated unlinked config for client {ClientId}", downloadClientId);
|
||||
|
||||
return Ok(existing);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for unlinked config update: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update unlinked config for client {ClientId}", downloadClientId);
|
||||
return StatusCode(500, new { Message = "Failed to update unlinked config", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner;
|
||||
|
||||
internal static class SeedingRuleHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Queries the appropriate per-type seeding rules table for a single client.
|
||||
/// </summary>
|
||||
public static async Task<List<ISeedingRule>> GetForClientAsync(DataContext ctx, DownloadClientConfig client)
|
||||
{
|
||||
return client.TypeName switch
|
||||
{
|
||||
DownloadClientTypeName.qBittorrent => (await ctx.QBitSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.Deluge => (await ctx.DelugeSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.Transmission => (await ctx.TransmissionSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.uTorrent => (await ctx.UTorrentSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.rTorrent => (await ctx.RTorrentSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries the appropriate per-type seeding rules table for a single client with change tracking enabled.
|
||||
/// Use this when you need to modify and save the returned entities.
|
||||
/// </summary>
|
||||
public static async Task<List<ISeedingRule>> GetForClientTrackedAsync(DataContext ctx, DownloadClientConfig client)
|
||||
{
|
||||
return client.TypeName switch
|
||||
{
|
||||
DownloadClientTypeName.qBittorrent => (await ctx.QBitSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.Deluge => (await ctx.DelugeSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.Transmission => (await ctx.TransmissionSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.uTorrent => (await ctx.UTorrentSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.rTorrent => (await ctx.RTorrentSeedingRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.ToListAsync()).Cast<ISeedingRule>().ToList(),
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the client by ID then queries its seeding rules.
|
||||
/// </summary>
|
||||
public static async Task<List<ISeedingRule>> GetForClientIdAsync(DataContext ctx, Guid clientId)
|
||||
{
|
||||
var client = await ctx.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == clientId);
|
||||
|
||||
return client is null ? [] : await GetForClientAsync(ctx, client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters seeding rules for a client from pre-loaded in-memory lists.
|
||||
/// Use this in bulk-load scenarios to avoid N+1 queries.
|
||||
/// </summary>
|
||||
public static List<ISeedingRule> FilterForClient(
|
||||
DownloadClientConfig client,
|
||||
List<QBitSeedingRule> qbitRules,
|
||||
List<DelugeSeedingRule> delugeRules,
|
||||
List<TransmissionSeedingRule> transmissionRules,
|
||||
List<UTorrentSeedingRule> utorrentRules,
|
||||
List<RTorrentSeedingRule> rtorrentRules)
|
||||
{
|
||||
return client.TypeName switch
|
||||
{
|
||||
DownloadClientTypeName.qBittorrent => qbitRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.Deluge => delugeRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.Transmission => transmissionRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.uTorrent => utorrentRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.Cast<ISeedingRule>().ToList(),
|
||||
DownloadClientTypeName.rTorrent => rtorrentRules
|
||||
.Where(r => r.DownloadClientConfigId == client.Id)
|
||||
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
|
||||
.Cast<ISeedingRule>().ToList(),
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches all five per-type seeding rule tables for a rule with the given ID.
|
||||
/// Returns the rule and a sentinel string identifying its type, or (null, null) if not found.
|
||||
/// </summary>
|
||||
public static async Task<(ISeedingRule? rule, object? dbSet)> FindByIdAsync(DataContext ctx, Guid id)
|
||||
{
|
||||
var qbit = await ctx.QBitSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (qbit is not null) return (qbit, ctx.QBitSeedingRules);
|
||||
|
||||
var deluge = await ctx.DelugeSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (deluge is not null) return (deluge, ctx.DelugeSeedingRules);
|
||||
|
||||
var transmission = await ctx.TransmissionSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (transmission is not null) return (transmission, ctx.TransmissionSeedingRules);
|
||||
|
||||
var utorrent = await ctx.UTorrentSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (utorrent is not null) return (utorrent, ctx.UTorrentSeedingRules);
|
||||
|
||||
var rtorrent = await ctx.RTorrentSeedingRules.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (rtorrent is not null) return (rtorrent, ctx.RTorrentSeedingRules);
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
-7
@@ -2,7 +2,6 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Serilog.Events;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
@@ -20,10 +19,6 @@ public sealed record UpdateGeneralConfigRequest
|
||||
|
||||
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
|
||||
|
||||
public bool SearchEnabled { get; init; } = true;
|
||||
|
||||
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
|
||||
|
||||
public bool StatusCheckEnabled { get; init; } = true;
|
||||
|
||||
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
|
||||
@@ -43,8 +38,6 @@ public sealed record UpdateGeneralConfigRequest
|
||||
existingConfig.HttpMaxRetries = HttpMaxRetries;
|
||||
existingConfig.HttpTimeout = HttpTimeout;
|
||||
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
|
||||
existingConfig.SearchEnabled = SearchEnabled;
|
||||
existingConfig.SearchDelay = SearchDelay;
|
||||
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
|
||||
existingConfig.EncryptionKey = EncryptionKey;
|
||||
existingConfig.IgnoredDownloads = IgnoredDownloads;
|
||||
|
||||
@@ -80,10 +80,14 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
.Where(d => !d.Strikes.Any())
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
var deletedHistory = await _dataContext.SeekerHistory
|
||||
.Where(h => h.IsDryRun)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items removed",
|
||||
deletedStrikes, deletedEvents, deletedManualEvents, deletedItems);
|
||||
|
||||
"Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries removed",
|
||||
deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
|
||||
+4
@@ -17,4 +17,8 @@ public abstract record CreateNotificationProviderRequestBase
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
|
||||
public bool OnSearchItemGrabbed { get; init; }
|
||||
}
|
||||
+4
@@ -17,4 +17,8 @@ public abstract record UpdateNotificationProviderRequestBase
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
|
||||
public bool OnSearchItemGrabbed { get; init; }
|
||||
}
|
||||
+55
-9
@@ -74,7 +74,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = p.OnSlowStrike,
|
||||
OnQueueItemDeleted = p.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = p.OnDownloadCleaned,
|
||||
OnCategoryChanged = p.OnCategoryChanged
|
||||
OnCategoryChanged = p.OnCategoryChanged,
|
||||
OnSearchTriggered = p.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = p.OnSearchItemGrabbed
|
||||
},
|
||||
Configuration = p.Type switch
|
||||
{
|
||||
@@ -153,6 +155,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
NotifiarrConfiguration = notifiarrConfig
|
||||
};
|
||||
|
||||
@@ -223,6 +227,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
AppriseConfiguration = appriseConfig
|
||||
};
|
||||
|
||||
@@ -300,6 +306,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
NtfyConfiguration = ntfyConfig
|
||||
};
|
||||
|
||||
@@ -368,6 +376,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
TelegramConfiguration = telegramConfig
|
||||
};
|
||||
|
||||
@@ -447,6 +457,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
NotifiarrConfiguration = notifiarrConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -533,6 +545,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
AppriseConfiguration = appriseConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -622,6 +636,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
NtfyConfiguration = ntfyConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -705,6 +721,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
TelegramConfiguration = telegramConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -815,7 +833,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
},
|
||||
Configuration = notifiarrConfig
|
||||
};
|
||||
@@ -882,7 +902,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
},
|
||||
Configuration = appriseConfig
|
||||
};
|
||||
@@ -956,7 +978,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
},
|
||||
Configuration = ntfyConfig
|
||||
};
|
||||
@@ -1013,7 +1037,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
},
|
||||
Configuration = telegramConfig
|
||||
};
|
||||
@@ -1048,7 +1074,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = provider.OnSlowStrike,
|
||||
OnQueueItemDeleted = provider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = provider.OnDownloadCleaned,
|
||||
OnCategoryChanged = provider.OnCategoryChanged
|
||||
OnCategoryChanged = provider.OnCategoryChanged,
|
||||
OnSearchTriggered = provider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = provider.OnSearchItemGrabbed
|
||||
},
|
||||
Configuration = provider.Type switch
|
||||
{
|
||||
@@ -1105,6 +1133,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
DiscordConfiguration = discordConfig
|
||||
};
|
||||
|
||||
@@ -1185,6 +1215,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
DiscordConfiguration = discordConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -1254,7 +1286,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
},
|
||||
Configuration = discordConfig
|
||||
};
|
||||
@@ -1325,6 +1359,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
PushoverConfiguration = pushoverConfig
|
||||
};
|
||||
|
||||
@@ -1412,6 +1448,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
PushoverConfiguration = pushoverConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -1495,7 +1533,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
},
|
||||
Configuration = pushoverConfig
|
||||
};
|
||||
@@ -1551,6 +1591,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = newProvider.OnSearchItemGrabbed,
|
||||
GotifyConfiguration = gotifyConfig
|
||||
};
|
||||
|
||||
@@ -1631,6 +1673,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
|
||||
GotifyConfiguration = gotifyConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -1698,7 +1742,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false,
|
||||
OnSearchItemGrabbed = false
|
||||
},
|
||||
Configuration = gotifyConfig
|
||||
};
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ public abstract record QueueRuleDto
|
||||
[Range(0, 100, ErrorMessage = "Minimum completion percentage must be between 0 and 100")]
|
||||
public ushort MinCompletionPercentage { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Maximum completion percentage must be between 0 and 100")]
|
||||
[Range(1, 100, ErrorMessage = "Maximum completion percentage must be between 1 and 100")]
|
||||
public ushort MaxCompletionPercentage { get; set; }
|
||||
|
||||
public bool DeletePrivateTorrentsFromClient { get; set; } = false;
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateSeekerConfigRequest
|
||||
{
|
||||
public bool SearchEnabled { get; init; } = true;
|
||||
|
||||
public ushort SearchInterval { get; init; } = 3;
|
||||
|
||||
public bool ProactiveSearchEnabled { get; init; }
|
||||
|
||||
public SelectionStrategy SelectionStrategy { get; init; } = SelectionStrategy.BalancedWeighted;
|
||||
|
||||
public bool UseRoundRobin { get; init; } = true;
|
||||
|
||||
public int PostReleaseGraceHours { get; init; } = 6;
|
||||
|
||||
public List<UpdateSeekerInstanceConfigRequest> Instances { get; init; } = [];
|
||||
|
||||
public SeekerConfig ApplyTo(SeekerConfig config)
|
||||
{
|
||||
config.SearchEnabled = SearchEnabled;
|
||||
config.SearchInterval = SearchInterval;
|
||||
config.ProactiveSearchEnabled = ProactiveSearchEnabled;
|
||||
config.SelectionStrategy = SelectionStrategy;
|
||||
config.UseRoundRobin = UseRoundRobin;
|
||||
config.PostReleaseGraceHours = PostReleaseGraceHours;
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
public Guid ArrInstanceId { get; init; }
|
||||
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
public List<string> SkipTags { get; init; } = [];
|
||||
|
||||
public int ActiveDownloadLimit { get; init; } = 3;
|
||||
|
||||
public int MinCycleTimeDays { get; init; } = 7;
|
||||
|
||||
public bool MonitoredOnly { get; init; } = true;
|
||||
|
||||
public bool UseCutoff { get; init; }
|
||||
|
||||
public bool UseCustomFormatScore { get; init; }
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record CustomFormatScoreEntryResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid ArrInstanceId { get; init; }
|
||||
public long ExternalItemId { get; init; }
|
||||
public long EpisodeId { get; init; }
|
||||
public InstanceType ItemType { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public long FileId { get; init; }
|
||||
public int CurrentScore { get; init; }
|
||||
public int CutoffScore { get; init; }
|
||||
public string QualityProfileName { get; init; } = string.Empty;
|
||||
public bool IsBelowCutoff { get; init; }
|
||||
public bool IsMonitored { get; init; }
|
||||
public DateTime LastSyncedAt { get; init; }
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record CustomFormatScoreHistoryEntryResponse
|
||||
{
|
||||
public int Score { get; init; }
|
||||
public int CutoffScore { get; init; }
|
||||
public DateTime RecordedAt { get; init; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record CustomFormatScoreStatsResponse
|
||||
{
|
||||
public int TotalTracked { get; init; }
|
||||
public int BelowCutoff { get; init; }
|
||||
public int AtOrAboveCutoff { get; init; }
|
||||
public int Monitored { get; init; }
|
||||
public int Unmonitored { get; init; }
|
||||
public int RecentUpgrades { get; init; }
|
||||
public List<InstanceCfScoreStat> PerInstanceStats { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record InstanceCfScoreStat
|
||||
{
|
||||
public Guid InstanceId { get; init; }
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
public string InstanceType { get; init; } = string.Empty;
|
||||
public int TotalTracked { get; init; }
|
||||
public int BelowCutoff { get; init; }
|
||||
public int AtOrAboveCutoff { get; init; }
|
||||
public int Monitored { get; init; }
|
||||
public int Unmonitored { get; init; }
|
||||
public int RecentUpgrades { get; init; }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record CustomFormatScoreUpgradeResponse
|
||||
{
|
||||
public Guid ArrInstanceId { get; init; }
|
||||
public long ExternalItemId { get; init; }
|
||||
public long EpisodeId { get; init; }
|
||||
public InstanceType ItemType { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public int PreviousScore { get; init; }
|
||||
public int NewScore { get; init; }
|
||||
public int CutoffScore { get; init; }
|
||||
public DateTime UpgradedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record InstanceSearchStat
|
||||
{
|
||||
public Guid InstanceId { get; init; }
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
public string InstanceType { get; init; } = string.Empty;
|
||||
public int ItemsTracked { get; init; }
|
||||
public int TotalSearchCount { get; init; }
|
||||
public DateTime? LastSearchedAt { get; init; }
|
||||
public DateTime? LastProcessedAt { get; init; }
|
||||
public Guid? CurrentCycleId { get; init; }
|
||||
public int CycleItemsSearched { get; init; }
|
||||
public int CycleItemsTotal { get; init; }
|
||||
public DateTime? CycleStartedAt { get; init; }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record SearchEventResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
public Guid? ArrInstanceId { get; init; }
|
||||
public string? InstanceType { get; init; }
|
||||
public string ItemTitle { get; init; } = string.Empty;
|
||||
public SeekerSearchType SearchType { get; init; }
|
||||
public SeekerSearchReason? SearchReason { get; init; }
|
||||
public SearchCommandStatus? SearchStatus { get; init; }
|
||||
public DateTime? CompletedAt { get; init; }
|
||||
public List<string> GrabbedItems { get; init; } = [];
|
||||
public Guid? CycleId { get; init; }
|
||||
public bool IsDryRun { get; init; }
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record SearchStatsSummaryResponse
|
||||
{
|
||||
public int TotalSearchesAllTime { get; init; }
|
||||
public int SearchesLast7Days { get; init; }
|
||||
public int SearchesLast30Days { get; init; }
|
||||
public int UniqueItemsSearched { get; init; }
|
||||
public int PendingReplacementSearches { get; init; }
|
||||
public int EnabledInstances { get; init; }
|
||||
public List<InstanceSearchStat> PerInstanceStats { get; init; } = [];
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record SeekerConfigResponse
|
||||
{
|
||||
public bool SearchEnabled { get; init; }
|
||||
|
||||
public ushort SearchInterval { get; init; }
|
||||
|
||||
public bool ProactiveSearchEnabled { get; init; }
|
||||
|
||||
public SelectionStrategy SelectionStrategy { get; init; }
|
||||
|
||||
public bool UseRoundRobin { get; init; }
|
||||
|
||||
public int PostReleaseGraceHours { get; init; }
|
||||
|
||||
public List<SeekerInstanceConfigResponse> Instances { get; init; } = [];
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record SeekerInstanceConfigResponse
|
||||
{
|
||||
public Guid ArrInstanceId { get; init; }
|
||||
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
|
||||
public InstanceType InstanceType { get; init; }
|
||||
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public List<string> SkipTags { get; init; } = [];
|
||||
|
||||
public DateTime? LastProcessedAt { get; init; }
|
||||
|
||||
public bool ArrInstanceEnabled { get; init; }
|
||||
|
||||
public int ActiveDownloadLimit { get; init; }
|
||||
|
||||
public int MinCycleTimeDays { get; init; }
|
||||
|
||||
public bool MonitoredOnly { get; init; }
|
||||
|
||||
public bool UseCutoff { get; init; }
|
||||
|
||||
public bool UseCustomFormatScore { get; init; }
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/seeker/cf-scores")]
|
||||
[Authorize]
|
||||
public sealed class CustomFormatScoreController : ControllerBase
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public CustomFormatScoreController(DataContext dataContext)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current CF scores with pagination, optionally filtered by instance.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomFormatScores(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] Guid? instanceId = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string sortBy = "title",
|
||||
[FromQuery] bool hideMet = false,
|
||||
[FromQuery] bool hideUnmonitored = false)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 50;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
var query = _dataContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
if (instanceId.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.ArrInstanceId == instanceId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(e => e.Title.ToLower().Contains(search.ToLower()));
|
||||
}
|
||||
|
||||
if (hideMet)
|
||||
{
|
||||
query = query.Where(e => e.CurrentScore < e.CutoffScore);
|
||||
}
|
||||
|
||||
if (hideUnmonitored)
|
||||
{
|
||||
query = query.Where(e => e.IsMonitored);
|
||||
}
|
||||
|
||||
int totalCount = await query.CountAsync();
|
||||
|
||||
var items = await (sortBy == "date"
|
||||
? query.OrderByDescending(e => e.LastSyncedAt)
|
||||
: query.OrderBy(e => e.Title))
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(e => new CustomFormatScoreEntryResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
ArrInstanceId = e.ArrInstanceId,
|
||||
ExternalItemId = e.ExternalItemId,
|
||||
EpisodeId = e.EpisodeId,
|
||||
ItemType = e.ItemType,
|
||||
Title = e.Title,
|
||||
FileId = e.FileId,
|
||||
CurrentScore = e.CurrentScore,
|
||||
CutoffScore = e.CutoffScore,
|
||||
QualityProfileName = e.QualityProfileName,
|
||||
IsBelowCutoff = e.CurrentScore < e.CutoffScore,
|
||||
IsMonitored = e.IsMonitored,
|
||||
LastSyncedAt = e.LastSyncedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = items,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent CF score upgrades (where score improved in history).
|
||||
/// </summary>
|
||||
[HttpGet("upgrades")]
|
||||
public async Task<IActionResult> GetRecentUpgrades(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] Guid? instanceId = null,
|
||||
[FromQuery] int days = 30)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
// Find history entries where a newer entry has a higher score than an older one
|
||||
// We group by item and look for score increases between consecutive records
|
||||
var query = _dataContext.CustomFormatScoreHistory
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
if (instanceId.HasValue)
|
||||
{
|
||||
query = query.Where(h => h.ArrInstanceId == instanceId.Value);
|
||||
}
|
||||
|
||||
var allHistory = await query
|
||||
.Where(h => h.RecordedAt >= DateTime.UtcNow.AddDays(-days))
|
||||
.OrderByDescending(h => h.RecordedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var upgrades = new List<CustomFormatScoreUpgradeResponse>();
|
||||
|
||||
// Group by (ArrInstanceId, ExternalItemId, EpisodeId) and find score increases
|
||||
var grouped = allHistory
|
||||
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var entries = group.OrderBy(h => h.RecordedAt).ToList();
|
||||
for (int i = 1; i < entries.Count; i++)
|
||||
{
|
||||
if (entries[i].Score > entries[i - 1].Score)
|
||||
{
|
||||
upgrades.Add(new CustomFormatScoreUpgradeResponse
|
||||
{
|
||||
ArrInstanceId = entries[i].ArrInstanceId,
|
||||
ExternalItemId = entries[i].ExternalItemId,
|
||||
EpisodeId = entries[i].EpisodeId,
|
||||
ItemType = entries[i].ItemType,
|
||||
Title = entries[i].Title,
|
||||
PreviousScore = entries[i - 1].Score,
|
||||
NewScore = entries[i].Score,
|
||||
CutoffScore = entries[i].CutoffScore,
|
||||
UpgradedAt = entries[i].RecordedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent upgrade first
|
||||
upgrades = upgrades.OrderByDescending(u => u.UpgradedAt).ToList();
|
||||
|
||||
int totalCount = upgrades.Count;
|
||||
var paged = upgrades
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = paged,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("instances")]
|
||||
public async Task<IActionResult> GetInstances()
|
||||
{
|
||||
var instances = await _dataContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.Select(e => new { e.ArrInstanceId, e.ItemType })
|
||||
.Distinct()
|
||||
.Join(
|
||||
_dataContext.ArrInstances.AsNoTracking(),
|
||||
e => e.ArrInstanceId,
|
||||
a => a.Id,
|
||||
(e, a) => new
|
||||
{
|
||||
Id = e.ArrInstanceId,
|
||||
a.Name,
|
||||
e.ItemType,
|
||||
})
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Instances = instances });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets summary statistics for CF score tracking.
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> GetStats()
|
||||
{
|
||||
var entries = await _dataContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
int totalTracked = entries.Count;
|
||||
int belowCutoff = entries.Count(e => e.CurrentScore < e.CutoffScore);
|
||||
int atOrAboveCutoff = totalTracked - belowCutoff;
|
||||
int monitored = entries.Count(e => e.IsMonitored);
|
||||
int unmonitored = totalTracked - monitored;
|
||||
|
||||
// Count upgrades in the last 7 days
|
||||
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
|
||||
var recentHistory = await _dataContext.CustomFormatScoreHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.RecordedAt >= sevenDaysAgo)
|
||||
.OrderBy(h => h.RecordedAt)
|
||||
.ToListAsync();
|
||||
|
||||
int recentUpgrades = 0;
|
||||
var recentGrouped = recentHistory
|
||||
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
|
||||
|
||||
foreach (var group in recentGrouped)
|
||||
{
|
||||
var ordered = group.OrderBy(h => h.RecordedAt).ToList();
|
||||
for (int i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
if (ordered[i].Score > ordered[i - 1].Score)
|
||||
recentUpgrades++;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-instance stats
|
||||
var instanceIds = entries.Select(e => e.ArrInstanceId).Distinct().ToList();
|
||||
var instances = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArrConfig)
|
||||
.Where(a => instanceIds.Contains(a.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var perInstanceStats = instanceIds.Select(instanceId =>
|
||||
{
|
||||
var instanceEntries = entries.Where(e => e.ArrInstanceId == instanceId).ToList();
|
||||
int instTracked = instanceEntries.Count;
|
||||
int instBelow = instanceEntries.Count(e => e.CurrentScore < e.CutoffScore);
|
||||
int instMonitored = instanceEntries.Count(e => e.IsMonitored);
|
||||
|
||||
int instUpgrades = 0;
|
||||
var instHistory = recentGrouped
|
||||
.Where(g => g.Key.ArrInstanceId == instanceId);
|
||||
foreach (var group in instHistory)
|
||||
{
|
||||
var ordered = group.OrderBy(h => h.RecordedAt).ToList();
|
||||
for (int i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
if (ordered[i].Score > ordered[i - 1].Score)
|
||||
instUpgrades++;
|
||||
}
|
||||
}
|
||||
|
||||
var instance = instances.FirstOrDefault(a => a.Id == instanceId);
|
||||
return new InstanceCfScoreStat
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
InstanceName = instance?.Name ?? "Unknown",
|
||||
InstanceType = instance?.ArrConfig.Type.ToString() ?? "Unknown",
|
||||
TotalTracked = instTracked,
|
||||
BelowCutoff = instBelow,
|
||||
AtOrAboveCutoff = instTracked - instBelow,
|
||||
Monitored = instMonitored,
|
||||
Unmonitored = instTracked - instMonitored,
|
||||
RecentUpgrades = instUpgrades,
|
||||
};
|
||||
}).OrderBy(s => s.InstanceName).ToList();
|
||||
|
||||
return Ok(new CustomFormatScoreStatsResponse
|
||||
{
|
||||
TotalTracked = totalTracked,
|
||||
BelowCutoff = belowCutoff,
|
||||
AtOrAboveCutoff = atOrAboveCutoff,
|
||||
Monitored = monitored,
|
||||
Unmonitored = unmonitored,
|
||||
RecentUpgrades = recentUpgrades,
|
||||
PerInstanceStats = perInstanceStats,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets CF score history for a specific item.
|
||||
/// </summary>
|
||||
[HttpGet("{instanceId}/{itemId}/history")]
|
||||
public async Task<IActionResult> GetItemHistory(
|
||||
Guid instanceId,
|
||||
long itemId,
|
||||
[FromQuery] long episodeId = 0)
|
||||
{
|
||||
var history = await _dataContext.CustomFormatScoreHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.ArrInstanceId == instanceId
|
||||
&& h.ExternalItemId == itemId
|
||||
&& h.EpisodeId == episodeId)
|
||||
.OrderByDescending(h => h.RecordedAt)
|
||||
.Select(h => new CustomFormatScoreHistoryEntryResponse
|
||||
{
|
||||
Score = h.Score,
|
||||
CutoffScore = h.CutoffScore,
|
||||
RecordedAt = h.RecordedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Entries = history });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/seeker/search-stats")]
|
||||
[Authorize]
|
||||
public sealed class SearchStatsController : ControllerBase
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
|
||||
public SearchStatsController(DataContext dataContext, EventsContext eventsContext)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
_eventsContext = eventsContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregate search statistics across all instances.
|
||||
/// </summary>
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> GetSummary()
|
||||
{
|
||||
DateTime sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
|
||||
DateTime thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
// Event counts from EventsContext
|
||||
var searchEvents = _eventsContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.EventType == EventType.SearchTriggered);
|
||||
|
||||
int totalSearchesAllTime = await searchEvents.CountAsync();
|
||||
int searchesLast7Days = await searchEvents.CountAsync(e => e.Timestamp >= sevenDaysAgo);
|
||||
int searchesLast30Days = await searchEvents.CountAsync(e => e.Timestamp >= thirtyDaysAgo);
|
||||
|
||||
// History stats from DataContext
|
||||
int uniqueItemsSearched = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Select(h => h.ExternalItemId)
|
||||
.Distinct()
|
||||
.CountAsync();
|
||||
|
||||
int pendingReplacementSearches = await _dataContext.SearchQueue.CountAsync();
|
||||
|
||||
// Per-instance stats
|
||||
List<SeekerInstanceConfig> instanceConfigs = await _dataContext.SeekerInstanceConfigs
|
||||
.AsNoTracking()
|
||||
.Include(s => s.ArrInstance)
|
||||
.ThenInclude(a => a.ArrConfig)
|
||||
.Where(s => s.Enabled && s.ArrInstance.Enabled)
|
||||
.ToListAsync();
|
||||
|
||||
var historyByInstance = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.GroupBy(h => h.ArrInstanceId)
|
||||
.Select(g => new
|
||||
{
|
||||
InstanceId = g.Key,
|
||||
ItemsTracked = g.Select(h => h.ExternalItemId).Distinct().Count(),
|
||||
LastSearchedAt = g.Max(h => h.LastSearchedAt),
|
||||
TotalSearchCount = g.Sum(h => h.SearchCount),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Count items searched in current cycle per instance
|
||||
List<Guid> currentCycleIds = instanceConfigs.Select(ic => ic.CurrentCycleId).ToList();
|
||||
var cycleItemsByInstance = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => currentCycleIds.Contains(h.CycleId))
|
||||
.GroupBy(h => h.ArrInstanceId)
|
||||
.Select(g => new
|
||||
{
|
||||
InstanceId = g.Key,
|
||||
CycleItemsSearched = g.Select(h => h.ExternalItemId).Distinct().Count(),
|
||||
CycleStartedAt = (DateTime?)g.Min(h => h.LastSearchedAt),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var perInstanceStats = instanceConfigs.Select(ic =>
|
||||
{
|
||||
var history = historyByInstance.FirstOrDefault(h => h.InstanceId == ic.ArrInstanceId);
|
||||
var cycleProgress = cycleItemsByInstance.FirstOrDefault(c => c.InstanceId == ic.ArrInstanceId);
|
||||
return new InstanceSearchStat
|
||||
{
|
||||
InstanceId = ic.ArrInstanceId,
|
||||
InstanceName = ic.ArrInstance.Name,
|
||||
InstanceType = ic.ArrInstance.ArrConfig.Type.ToString(),
|
||||
ItemsTracked = history?.ItemsTracked ?? 0,
|
||||
TotalSearchCount = history?.TotalSearchCount ?? 0,
|
||||
LastSearchedAt = history?.LastSearchedAt,
|
||||
LastProcessedAt = ic.LastProcessedAt,
|
||||
CurrentCycleId = ic.CurrentCycleId,
|
||||
CycleItemsSearched = cycleProgress?.CycleItemsSearched ?? 0,
|
||||
CycleItemsTotal = ic.TotalEligibleItems,
|
||||
CycleStartedAt = cycleProgress?.CycleStartedAt,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(new SearchStatsSummaryResponse
|
||||
{
|
||||
TotalSearchesAllTime = totalSearchesAllTime,
|
||||
SearchesLast7Days = searchesLast7Days,
|
||||
SearchesLast30Days = searchesLast30Days,
|
||||
UniqueItemsSearched = uniqueItemsSearched,
|
||||
PendingReplacementSearches = pendingReplacementSearches,
|
||||
EnabledInstances = instanceConfigs.Count,
|
||||
PerInstanceStats = perInstanceStats,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets paginated search-triggered events
|
||||
/// </summary>
|
||||
[HttpGet("events")]
|
||||
public async Task<IActionResult> GetEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] Guid? instanceId = null,
|
||||
[FromQuery] Guid? cycleId = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 50;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
var query = _eventsContext.Events
|
||||
.AsNoTracking()
|
||||
.Include(e => e.SearchEventData)
|
||||
.Where(e => e.EventType == EventType.SearchTriggered);
|
||||
|
||||
// Filter by instance ID
|
||||
if (instanceId.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.ArrInstanceId == instanceId.Value);
|
||||
}
|
||||
|
||||
// Filter by cycle ID
|
||||
if (cycleId.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.CycleId == cycleId.Value);
|
||||
}
|
||||
|
||||
// Search by item title in SearchEventData
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e => e.SearchEventData != null
|
||||
&& EF.Functions.Like(e.SearchEventData.ItemTitle, pattern));
|
||||
}
|
||||
|
||||
int totalCount = await query.CountAsync();
|
||||
|
||||
var rawEvents = await query
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
// Resolve instance types from DataContext via ArrInstanceId
|
||||
var arrInstanceIds = rawEvents
|
||||
.Where(e => e.ArrInstanceId.HasValue)
|
||||
.Select(e => e.ArrInstanceId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var instanceTypeMap = arrInstanceIds.Count > 0
|
||||
? await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArrConfig)
|
||||
.Where(a => arrInstanceIds.Contains(a.Id))
|
||||
.ToDictionaryAsync(a => a.Id, a => a.ArrConfig.Type)
|
||||
: new Dictionary<Guid, InstanceType>();
|
||||
|
||||
var items = rawEvents.Select(e => new SearchEventResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
Timestamp = e.Timestamp,
|
||||
ArrInstanceId = e.ArrInstanceId,
|
||||
InstanceType = e.ArrInstanceId.HasValue && instanceTypeMap.TryGetValue(e.ArrInstanceId.Value, out var it)
|
||||
? it.ToString()
|
||||
: null,
|
||||
ItemTitle = e.SearchEventData?.ItemTitle ?? "Unknown",
|
||||
SearchType = e.SearchEventData?.SearchType ?? SeekerSearchType.Proactive,
|
||||
SearchReason = e.SearchEventData?.SearchReason,
|
||||
SearchStatus = e.SearchStatus,
|
||||
CompletedAt = e.CompletedAt,
|
||||
GrabbedItems = e.SearchEventData?.GrabbedItems ?? [],
|
||||
CycleId = e.CycleId,
|
||||
IsDryRun = e.IsDryRun,
|
||||
}).ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = items,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class SeekerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<SeekerConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public SeekerConfigController(
|
||||
ILogger<SeekerConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("seeker")]
|
||||
public async Task<IActionResult> GetSeekerConfig()
|
||||
{
|
||||
var config = await _dataContext.SeekerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
// Get all Sonarr/Radarr instances with their seeker configs
|
||||
var arrInstances = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArrConfig)
|
||||
.Where(a => a.ArrConfig.Type == InstanceType.Sonarr || a.ArrConfig.Type == InstanceType.Radarr)
|
||||
.ToListAsync();
|
||||
|
||||
var arrInstanceIds = arrInstances.Select(a => a.Id).ToHashSet();
|
||||
var seekerInstanceConfigs = await _dataContext.SeekerInstanceConfigs
|
||||
.AsNoTracking()
|
||||
.Where(s => arrInstanceIds.Contains(s.ArrInstanceId))
|
||||
.ToListAsync();
|
||||
|
||||
var instanceResponses = arrInstances.Select(instance =>
|
||||
{
|
||||
var seekerConfig = seekerInstanceConfigs.FirstOrDefault(s => s.ArrInstanceId == instance.Id);
|
||||
return new SeekerInstanceConfigResponse
|
||||
{
|
||||
ArrInstanceId = instance.Id,
|
||||
InstanceName = instance.Name,
|
||||
InstanceType = instance.ArrConfig.Type,
|
||||
Enabled = seekerConfig?.Enabled ?? false,
|
||||
SkipTags = seekerConfig?.SkipTags ?? [],
|
||||
LastProcessedAt = seekerConfig?.LastProcessedAt,
|
||||
ArrInstanceEnabled = instance.Enabled,
|
||||
ActiveDownloadLimit = seekerConfig?.ActiveDownloadLimit ?? 3,
|
||||
MinCycleTimeDays = seekerConfig?.MinCycleTimeDays ?? 7,
|
||||
MonitoredOnly = seekerConfig?.MonitoredOnly ?? true,
|
||||
UseCutoff = seekerConfig?.UseCutoff ?? false,
|
||||
UseCustomFormatScore = seekerConfig?.UseCustomFormatScore ?? false,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var response = new SeekerConfigResponse
|
||||
{
|
||||
SearchEnabled = config.SearchEnabled,
|
||||
SearchInterval = config.SearchInterval,
|
||||
ProactiveSearchEnabled = config.ProactiveSearchEnabled,
|
||||
SelectionStrategy = config.SelectionStrategy,
|
||||
UseRoundRobin = config.UseRoundRobin,
|
||||
PostReleaseGraceHours = config.PostReleaseGraceHours,
|
||||
Instances = instanceResponses,
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("seeker")]
|
||||
public async Task<IActionResult> UpdateSeekerConfig([FromBody] UpdateSeekerConfigRequest request)
|
||||
{
|
||||
if (!await DataContext.Lock.WaitAsync(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
return StatusCode(503, "Database is busy, please try again");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
|
||||
ushort previousInterval = config.SearchInterval;
|
||||
bool previousSearchEnabled = config.SearchEnabled;
|
||||
bool previousProactiveSearchEnabled = config.ProactiveSearchEnabled;
|
||||
|
||||
request.ApplyTo(config);
|
||||
config.Validate();
|
||||
|
||||
if (request.ProactiveSearchEnabled && !request.Instances.Any(i => i.Enabled))
|
||||
{
|
||||
throw new Domain.Exceptions.ValidationException(
|
||||
"At least one instance must be enabled when proactive search is enabled");
|
||||
}
|
||||
|
||||
// Sync instance configs
|
||||
var existingInstanceConfigs = await _dataContext.SeekerInstanceConfigs
|
||||
.Include(e => e.ArrInstance)
|
||||
.ToListAsync();
|
||||
bool previousAnyUseCustomFormatScore = existingInstanceConfigs.Any(e => e.Enabled && e.ArrInstance.Enabled && e.UseCustomFormatScore);
|
||||
|
||||
foreach (var instanceReq in request.Instances)
|
||||
{
|
||||
var existing = existingInstanceConfigs
|
||||
.FirstOrDefault(e => e.ArrInstanceId == instanceReq.ArrInstanceId);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Enabled = instanceReq.Enabled;
|
||||
existing.SkipTags = instanceReq.SkipTags;
|
||||
existing.ActiveDownloadLimit = instanceReq.ActiveDownloadLimit;
|
||||
existing.MinCycleTimeDays = instanceReq.MinCycleTimeDays;
|
||||
existing.MonitoredOnly = instanceReq.MonitoredOnly;
|
||||
existing.UseCutoff = instanceReq.UseCutoff;
|
||||
existing.UseCustomFormatScore = instanceReq.UseCustomFormatScore;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = instanceReq.ArrInstanceId,
|
||||
Enabled = instanceReq.Enabled,
|
||||
SkipTags = instanceReq.SkipTags,
|
||||
ActiveDownloadLimit = instanceReq.ActiveDownloadLimit,
|
||||
MinCycleTimeDays = instanceReq.MinCycleTimeDays,
|
||||
MonitoredOnly = instanceReq.MonitoredOnly,
|
||||
UseCutoff = instanceReq.UseCutoff,
|
||||
UseCustomFormatScore = instanceReq.UseCustomFormatScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
bool anyUseCustomFormatScore = await _dataContext.SeekerInstanceConfigs
|
||||
.AnyAsync(s => s.Enabled && s.ArrInstance.Enabled && s.UseCustomFormatScore);
|
||||
|
||||
// Start/stop Seeker based on SearchEnabled toggle
|
||||
if (config.SearchEnabled != previousSearchEnabled)
|
||||
{
|
||||
if (config.SearchEnabled)
|
||||
{
|
||||
_logger.LogInformation("SearchEnabled turned on, starting Seeker job");
|
||||
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("SearchEnabled turned off, stopping Seeker job");
|
||||
await _jobManagementService.StopJob(JobType.Seeker);
|
||||
}
|
||||
}
|
||||
// Update Quartz trigger if SearchInterval changed (only while search is enabled)
|
||||
else if (config.SearchEnabled && config.SearchInterval != previousInterval)
|
||||
{
|
||||
_logger.LogInformation("Search interval changed from {Old} to {New} minutes, updating Seeker schedule",
|
||||
previousInterval, config.SearchInterval);
|
||||
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
|
||||
}
|
||||
|
||||
// Toggle CustomFormatScoreSyncer job when any instance's UseCustomFormatScore changes
|
||||
if (anyUseCustomFormatScore != previousAnyUseCustomFormatScore)
|
||||
{
|
||||
if (anyUseCustomFormatScore)
|
||||
{
|
||||
_logger.LogInformation("UseCustomFormatScore enabled on an instance, starting CustomFormatScoreSyncer job");
|
||||
await _jobManagementService.StartJob(JobType.CustomFormatScoreSyncer, null, Constants.CustomFormatScoreSyncerCron);
|
||||
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("UseCustomFormatScore disabled on all instances, stopping CustomFormatScoreSyncer job");
|
||||
await _jobManagementService.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger CustomFormatScoreSyncer once when search or proactive search is re-enabled with custom format scores active
|
||||
if (previousAnyUseCustomFormatScore && anyUseCustomFormatScore)
|
||||
{
|
||||
bool searchJustEnabled = !previousSearchEnabled && config.SearchEnabled;
|
||||
bool proactiveJustEnabled = !previousProactiveSearchEnabled && config.ProactiveSearchEnabled;
|
||||
|
||||
if (searchJustEnabled || proactiveJustEnabled)
|
||||
{
|
||||
_logger.LogInformation("Search re-enabled with UseCustomFormatScore active, triggering CustomFormatScoreSyncer");
|
||||
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { Message = "Seeker configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save Seeker configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,14 @@ public static class HostExtensions
|
||||
|
||||
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
|
||||
{
|
||||
// Apply data db migrations first — events migrations may ATTACH cleanuparr.db
|
||||
// and reference its schema, so it must be up to date before events migrate.
|
||||
await using var configContext = DataContext.CreateStaticInstance();
|
||||
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Apply events db migrations
|
||||
await using var eventsContext = EventsContext.CreateStaticInstance();
|
||||
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
@@ -55,13 +63,6 @@ public static class HostExtensions
|
||||
await eventsContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Apply data db migrations
|
||||
await using var configContext = DataContext.CreateStaticInstance();
|
||||
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Apply users db migrations
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
if ((await usersContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
|
||||
@@ -6,6 +6,8 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Quartz;
|
||||
@@ -100,12 +102,17 @@ public class BackgroundJobManager : IHostedService
|
||||
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
SeekerConfig seekerConfig = await dataContext.SeekerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
await RegisterSeekerJob(seekerConfig, cancellationToken);
|
||||
await RegisterCustomFormatScoreSyncJob(dataContext, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -171,6 +178,36 @@ public class BackgroundJobManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Seeker job with a trigger based on SearchInterval.
|
||||
/// The Seeker is always running.
|
||||
/// </summary>
|
||||
public async Task RegisterSeekerJob(SeekerConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AddJobWithoutTrigger<SeekerJob>(cancellationToken);
|
||||
if (config.SearchEnabled)
|
||||
{
|
||||
await AddTriggersForJob<SeekerJob>(config.ToCronExpression(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the CustomFormatScoreSyncer job. Only adds triggers when at least one instance has UseCustomFormatScore enabled.
|
||||
/// Runs every 30 minutes to sync custom format scores from arr instances.
|
||||
/// </summary>
|
||||
public async Task RegisterCustomFormatScoreSyncJob(DataContext dataContext, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AddJobWithoutTrigger<CustomFormatScoreSyncer>(cancellationToken);
|
||||
|
||||
bool anyUseCustomFormatScore = await dataContext.SeekerInstanceConfigs
|
||||
.AnyAsync(s => s.Enabled && s.ArrInstance.Enabled && s.UseCustomFormatScore, cancellationToken);
|
||||
|
||||
if (anyUseCustomFormatScore)
|
||||
{
|
||||
await AddTriggersForJob<CustomFormatScoreSyncer>(Constants.CustomFormatScoreSyncerCron, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to add triggers for an existing job.
|
||||
/// </summary>
|
||||
@@ -204,7 +241,11 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
if (typeof(T) == typeof(SeekerJob) && triggerValue < Constants.SeekerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.SeekerMinLimit.TotalMinutes} minutes");
|
||||
}
|
||||
else if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class GenericJob<T> : IJob
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<T>();
|
||||
await handler.ExecuteAsync();
|
||||
await handler.ExecuteAsync(context.CancellationToken);
|
||||
|
||||
status = JobRunStatus.Completed;
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, jobType, true);
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class BlacklistSynchronizer : IHandler
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
@@ -73,7 +73,7 @@ public sealed class BlacklistSynchronizer : IHandler
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record ArrCommandStatus(long Id, string Status, string? Message);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record ArrEpisodeFile
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public bool QualityCutoffNotMet { get; init; }
|
||||
|
||||
public int CustomFormatScore { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record ArrQualityProfile
|
||||
{
|
||||
public int Id { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public int CutoffFormatScore { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the custom format score data from a movie/episode file API response
|
||||
/// </summary>
|
||||
public sealed record MediaFileScore
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public int CustomFormatScore { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record MovieFileInfo
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public bool QualityCutoffNotMet { get; init; }
|
||||
}
|
||||
@@ -37,4 +37,5 @@ public sealed record QueueRecord
|
||||
public required string DownloadId { get; init; }
|
||||
public required string Protocol { get; init; }
|
||||
public required long Id { get; init; }
|
||||
public long SizeLeft { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr;
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public class SearchItem
|
||||
{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SearchableEpisode
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public int SeasonNumber { get; init; }
|
||||
|
||||
public int EpisodeNumber { get; init; }
|
||||
|
||||
public bool Monitored { get; init; }
|
||||
|
||||
public DateTime? AirDateUtc { get; init; }
|
||||
|
||||
public bool HasFile { get; init; }
|
||||
|
||||
public long EpisodeFileId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SearchableMovie
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public bool Monitored { get; init; }
|
||||
|
||||
public bool HasFile { get; init; }
|
||||
|
||||
public MovieFileInfo? MovieFile { get; init; }
|
||||
|
||||
public List<long> Tags { get; init; } = [];
|
||||
|
||||
public int QualityProfileId { get; init; }
|
||||
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
public DateTime? Added { get; init; }
|
||||
|
||||
public DateTime? DigitalRelease { get; init; }
|
||||
|
||||
public DateTime? PhysicalRelease { get; init; }
|
||||
|
||||
public DateTime? InCinemas { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SearchableSeries
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public int QualityProfileId { get; init; }
|
||||
|
||||
public bool Monitored { get; init; }
|
||||
|
||||
public List<long> Tags { get; init; } = [];
|
||||
|
||||
public DateTime? Added { get; init; }
|
||||
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
public SeriesStatistics? Statistics { get; init; }
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SeriesStatistics
|
||||
{
|
||||
public int EpisodeFileCount { get; init; }
|
||||
|
||||
public int EpisodeCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record Tag
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
|
||||
public required string Label { get; init; }
|
||||
}
|
||||
@@ -27,11 +27,23 @@ public interface ITorrentItemWrapper
|
||||
long SeedingTimeSeconds { get; }
|
||||
|
||||
string? Category { get; set; }
|
||||
|
||||
|
||||
string SavePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tracker domains extracted from all trackers associated with this torrent.
|
||||
/// Used for tracker-based seeding rule matching.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> TrackerDomains { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tags or labels associated with this torrent.
|
||||
/// Populated for qBittorrent (tags) and Transmission (labels). Empty for other clients.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> Tags { get; }
|
||||
|
||||
bool IsDownloading();
|
||||
|
||||
|
||||
bool IsStalled();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -61,10 +61,17 @@ public sealed record RTorrentTorrent
|
||||
public string? Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base path where the torrent data is stored
|
||||
/// Base path where the torrent data is stored.
|
||||
/// For multi-file torrents this is the torrent directory; for single-file torrents this is the full file path.
|
||||
/// </summary>
|
||||
public string? BasePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory containing the torrent data (from d.directory).
|
||||
/// Unlike BasePath, this always points to a directory for both single-file and multi-file torrents.
|
||||
/// </summary>
|
||||
public string? Directory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of tracker URLs for this torrent
|
||||
/// </summary>
|
||||
|
||||
@@ -10,5 +10,6 @@ public enum EventType
|
||||
QueueItemDeleted,
|
||||
DownloadCleaned,
|
||||
CategoryChanged,
|
||||
DownloadMarkedForDeletion
|
||||
DownloadMarkedForDeletion,
|
||||
SearchTriggered,
|
||||
}
|
||||
@@ -6,4 +6,6 @@ public enum JobType
|
||||
MalwareBlocker,
|
||||
DownloadCleaner,
|
||||
BlacklistSynchronizer,
|
||||
Seeker,
|
||||
CustomFormatScoreSyncer,
|
||||
}
|
||||
@@ -9,5 +9,7 @@ public enum NotificationEventType
|
||||
SlowTimeStrike,
|
||||
QueueItemDeleted,
|
||||
DownloadCleaned,
|
||||
CategoryChanged
|
||||
CategoryChanged,
|
||||
SearchTriggered,
|
||||
SearchItemGrabbed
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SearchCommandStatus
|
||||
{
|
||||
Pending,
|
||||
Started,
|
||||
Completed,
|
||||
Failed,
|
||||
TimedOut
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SeekerSearchReason
|
||||
{
|
||||
Missing,
|
||||
QualityCutoffNotMet,
|
||||
CustomFormatScoreBelowCutoff,
|
||||
Replacement,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SeekerSearchType
|
||||
{
|
||||
Proactive,
|
||||
Replacement
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum SelectionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Weighted random selection combining search recency and add date.
|
||||
/// Items that are both recently added and haven't been searched
|
||||
/// get the highest priority. Best all-around strategy for mixed libraries.
|
||||
/// </summary>
|
||||
BalancedWeighted,
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic selection of items with the oldest (or no) search history first.
|
||||
/// Provides systematic, sequential coverage of your entire library.
|
||||
/// </summary>
|
||||
OldestSearchFirst,
|
||||
|
||||
/// <summary>
|
||||
/// Weighted random selection based on search recency.
|
||||
/// Items that haven't been searched recently are ranked higher and more likely to be selected,
|
||||
/// while recently-searched items still have a chance proportional to their rank.
|
||||
/// </summary>
|
||||
OldestSearchWeighted,
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic selection of the most recently added items first.
|
||||
/// Always picks the newest content in your library.
|
||||
/// </summary>
|
||||
NewestFirst,
|
||||
|
||||
/// <summary>
|
||||
/// Weighted random selection based on when items were added.
|
||||
/// Recently added items are ranked higher and more likely to be selected,
|
||||
/// while older items still have a chance proportional to their rank.
|
||||
/// </summary>
|
||||
NewestWeighted,
|
||||
|
||||
/// <summary>
|
||||
/// Pure random selection with no weighting or bias.
|
||||
/// Every eligible item has an equal chance of being selected.
|
||||
/// </summary>
|
||||
Random,
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum SeriesSearchType
|
||||
{
|
||||
Episode,
|
||||
Season,
|
||||
Series
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,8 @@ public sealed class ValidationException : Exception
|
||||
public ValidationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ValidationException(string message, Exception inner) : base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@
|
||||
<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" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
|
||||
+12
-17
@@ -1,11 +1,13 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
@@ -16,7 +18,7 @@ namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
|
||||
private readonly ILogger<EventCleanupService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _dbName;
|
||||
|
||||
@@ -30,7 +32,7 @@ public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
options.UseInMemoryDatabase(databaseName: _dbName));
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_loggerMock = new Mock<ILogger<EventCleanupService>>();
|
||||
_logger = Substitute.For<ILogger<EventCleanupService>>();
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
_context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
@@ -76,7 +78,7 @@ public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
var count = await context.Events.CountAsync();
|
||||
Assert.Equal(2, count);
|
||||
count.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +87,7 @@ public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var service = new EventCleanupService(_logger, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
@@ -98,7 +100,7 @@ public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - the service should complete without throwing
|
||||
Assert.True(true);
|
||||
true.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,7 +110,7 @@ public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
// 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 service = new EventCleanupService(_logger, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
@@ -118,13 +120,6 @@ public class EventCleanupServiceIntegrationTests : IDisposable
|
||||
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);
|
||||
_logger.ReceivedLogContainingAtLeastOnce(LogLevel.Error, "Failed to perform event cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
|
||||
public class EventCleanupServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<EventCleanupService>> _loggerMock;
|
||||
private readonly ILogger<EventCleanupService> _logger;
|
||||
private readonly ServiceCollection _services;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _dbName;
|
||||
|
||||
public EventCleanupServiceTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<EventCleanupService>>();
|
||||
_logger = Substitute.For<ILogger<EventCleanupService>>();
|
||||
_services = new ServiceCollection();
|
||||
_dbName = Guid.NewGuid().ToString();
|
||||
|
||||
@@ -43,7 +45,7 @@ public class EventCleanupServiceTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var service = new EventCleanupService(_logger, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - start and immediately cancel
|
||||
@@ -53,14 +55,7 @@ public class EventCleanupServiceTests : IDisposable
|
||||
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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Information, "started");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,7 +63,7 @@ public class EventCleanupServiceTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var service = new EventCleanupService(_logger, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
@@ -78,14 +73,7 @@ public class EventCleanupServiceTests : IDisposable
|
||||
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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Information, "stopping");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -95,10 +83,10 @@ public class EventCleanupServiceTests : IDisposable
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
|
||||
// Act
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var service = new EventCleanupService(_logger, scopeFactory);
|
||||
|
||||
// Assert - service should be created without exception
|
||||
Assert.NotNull(service);
|
||||
service.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,7 +94,7 @@ public class EventCleanupServiceTests : IDisposable
|
||||
{
|
||||
// Arrange
|
||||
var scopeFactory = _serviceProvider.GetRequiredService<IServiceScopeFactory>();
|
||||
var service = new EventCleanupService(_loggerMock.Object, scopeFactory);
|
||||
var service = new EventCleanupService(_logger, scopeFactory);
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - cancel immediately
|
||||
@@ -118,13 +106,6 @@ public class EventCleanupServiceTests : IDisposable
|
||||
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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Information, "stopped");
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ 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.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Events;
|
||||
@@ -18,11 +20,10 @@ 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 IHubContext<AppHub> _hubContext;
|
||||
private readonly INotificationPublisher _notificationPublisher;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
private readonly IClientProxy _clientProxy;
|
||||
private readonly EventPublisher _publisher;
|
||||
|
||||
public EventPublisherTests()
|
||||
@@ -30,30 +31,30 @@ public class EventPublisherTests : IDisposable
|
||||
// Setup in-memory database
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.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>();
|
||||
_hubContext = Substitute.For<IHubContext<AppHub>>();
|
||||
_notificationPublisher = Substitute.For<INotificationPublisher>();
|
||||
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
_clientProxy = Substitute.For<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);
|
||||
var clients = Substitute.For<IHubClients>();
|
||||
clients.All.Returns(_clientProxy);
|
||||
_hubContext.Clients.Returns(clients);
|
||||
|
||||
// Setup dry run interceptor to report dry run as disabled by default
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
|
||||
_dryRunInterceptor.IsDryRunEnabled().Returns(false);
|
||||
|
||||
_publisher = new EventPublisher(
|
||||
_context,
|
||||
_hubContextMock.Object,
|
||||
_loggerMock.Object,
|
||||
_notificationPublisherMock.Object,
|
||||
_dryRunInterceptorMock.Object);
|
||||
_hubContext,
|
||||
Substitute.For<ILogger<EventPublisher>>(),
|
||||
_notificationPublisher,
|
||||
_dryRunInterceptor);
|
||||
|
||||
// Setup JobRunId in context for tests
|
||||
ContextProvider.SetJobRunId(Guid.NewGuid());
|
||||
@@ -80,10 +81,10 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// 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);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.EventType.ShouldBe(eventType);
|
||||
savedEvent.Message.ShouldBe(message);
|
||||
savedEvent.Severity.ShouldBe(severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -100,10 +101,10 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("TestDownload", savedEvent.Data);
|
||||
Assert.Contains("abc123", savedEvent.Data);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldContain("TestDownload");
|
||||
savedEvent.Data.ShouldContain("abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -120,8 +121,8 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(trackingId, savedEvent.TrackingId);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.TrackingId.ShouldBe(trackingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -136,10 +137,10 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
await _clientProxy.Received(1).SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -150,10 +151,10 @@ public class EventPublisherTests : IDisposable
|
||||
var message = "Test message";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
_clientProxyMock.Setup(c => c.SendCoreAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<object[]>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
_clientProxy.SendCoreAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<object[]>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new Exception("SignalR connection failed"));
|
||||
|
||||
// Act - should not throw
|
||||
@@ -161,7 +162,7 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert - verify event was still saved
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -177,8 +178,8 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Null(savedEvent.Data);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -197,9 +198,9 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(message, savedEvent.Message);
|
||||
Assert.Equal(severity, savedEvent.Severity);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Message.ShouldBe(message);
|
||||
savedEvent.Severity.ShouldBe(severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -215,10 +216,10 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("TestItem", savedEvent.Data);
|
||||
Assert.Contains("5", savedEvent.Data);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldContain("TestItem");
|
||||
savedEvent.Data.ShouldContain("5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -232,10 +233,10 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
await _clientProxy.Received(1).SendCoreAsync(
|
||||
"ManualEventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
Arg.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -254,7 +255,7 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
|
||||
await _dryRunInterceptor.Received(1).IsDryRunEnabled();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -268,14 +269,14 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
|
||||
await _dryRunInterceptor.Received(1).IsDryRunEnabled();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Dry run event";
|
||||
var severity = EventSeverity.Warning;
|
||||
@@ -285,8 +286,8 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.True(savedEvent.IsDryRun);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.IsDryRun.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -302,15 +303,15 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.False(savedEvent.IsDryRun);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.IsDryRun.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
|
||||
var message = "Dry run manual event";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
@@ -319,15 +320,15 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.True(savedEvent.IsDryRun);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.IsDryRun.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenDryRunEnabled_StillSavesToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Should be saved";
|
||||
var severity = EventSeverity.Warning;
|
||||
@@ -337,8 +338,8 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(message, savedEvent.Message);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Message.ShouldBe(message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -359,9 +360,9 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Stalled", savedEvent.Data);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldContain("Stalled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -383,10 +384,10 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("item1", savedEvent.Data);
|
||||
Assert.Contains("123", savedEvent.Data);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldContain("item1");
|
||||
savedEvent.Data.ShouldContain("123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -405,13 +406,13 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// 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);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.EventType.ShouldBe(EventType.QueueItemDeleted);
|
||||
savedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
savedEvent.Data.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldContain("Test Download");
|
||||
savedEvent.Data.ShouldContain("abc123");
|
||||
savedEvent.Data.ShouldContain("Stalled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -425,7 +426,7 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyQueueItemDeleted(false, DeleteReason.FailedImport), Times.Once);
|
||||
await _notificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.FailedImport);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -448,14 +449,14 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// 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);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.EventType.ShouldBe(EventType.DownloadCleaned);
|
||||
savedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
savedEvent.Data.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldContain("Cleaned Download");
|
||||
savedEvent.Data.ShouldContain("def456");
|
||||
savedEvent.Data.ShouldContain("movies");
|
||||
savedEvent.Data.ShouldContain("MaxSeedTimeReached");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -474,7 +475,7 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishDownloadCleaned(ratio, seedingTime, categoryName, reason);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason), Times.Once);
|
||||
await _notificationPublisher.Received(1).NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -493,12 +494,12 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// 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);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Severity.ShouldBe(EventSeverity.Warning);
|
||||
savedEvent.Message.ShouldContain("Replacement search was not triggered");
|
||||
savedEvent.Data.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldContain("Test Item");
|
||||
savedEvent.Data.ShouldContain("abc123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -517,12 +518,12 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// 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);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Severity.ShouldBe(EventSeverity.Important);
|
||||
savedEvent.Message.ShouldContain("keeps coming back");
|
||||
savedEvent.Data.ShouldNotBeNull();
|
||||
savedEvent.Data.ShouldContain("Recurring Item");
|
||||
savedEvent.Data.ShouldContain("hash123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -541,10 +542,10 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// 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);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.EventType.ShouldBe(EventType.CategoryChanged);
|
||||
savedEvent.Severity.ShouldBe(EventSeverity.Information);
|
||||
savedEvent.Message.ShouldContain("Category changed from 'oldCat' to 'newCat'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -559,8 +560,8 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Contains("Tag 'cleanuperr-done' added", savedEvent.Message);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Message.ShouldContain("Tag 'cleanuperr-done' added");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -574,7 +575,242 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishCategoryChanged("old", "new", isTag: true);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(n => n.NotifyCategoryChanged("old", "new", true), Times.Once);
|
||||
await _notificationPublisher.Received(1).NotifyCategoryChanged("old", "new", true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishSearchTriggered Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SavesEventWithCorrectType()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.EventType.ShouldBe(EventType.SearchTriggered);
|
||||
savedEvent.Severity.ShouldBe(EventSeverity.Information);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SetsSearchStatusToPending()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.SearchStatus.ShouldBe(SearchCommandStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SetsCycleId()
|
||||
{
|
||||
// Arrange
|
||||
var cycleId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing, cycleId);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.CycleId.ShouldBe(cycleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_ReturnsEventId()
|
||||
{
|
||||
// Act
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
eventId.ShouldNotBe(Guid.Empty);
|
||||
var savedEvent = await _context.Events.FindAsync(eventId);
|
||||
savedEvent.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_CreatesSearchEventData()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Series A", SeekerSearchType.Replacement, SeekerSearchReason.Replacement);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
savedEvent.ShouldNotBeNull();
|
||||
|
||||
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == savedEvent.Id);
|
||||
searchData.ShouldNotBeNull();
|
||||
searchData.ItemTitle.ShouldBe("Series A");
|
||||
searchData.SearchType.ShouldBe(SeekerSearchType.Replacement);
|
||||
searchData.SearchReason.ShouldBe(SeekerSearchReason.Replacement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_NotifiesSignalRClients()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
await _clientProxy.Received(1).SendCoreAsync(
|
||||
"EventReceived",
|
||||
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SendsNotification()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
await _notificationPublisher.Received(1).NotifySearchTriggered(
|
||||
"Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_IncludesItemTitleInMessage()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("The Matrix", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
savedEvent.ShouldNotBeNull();
|
||||
savedEvent.Message.ShouldContain("The Matrix");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishSearchCompleted Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_UpdatesEventStatus()
|
||||
{
|
||||
// Arrange — create a search event first
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878");
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
updatedEvent.ShouldNotBeNull();
|
||||
updatedEvent.SearchStatus.ShouldBe(SearchCommandStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_SetsCompletedAt()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878");
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
updatedEvent.ShouldNotBeNull();
|
||||
updatedEvent.CompletedAt.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_UpdatesGrabbedItemsOnSearchEventData()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
var grabbedItems = new List<string> { "Movie A (2024)" };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878", grabbedItems);
|
||||
|
||||
// Assert
|
||||
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
|
||||
searchData.ShouldNotBeNull();
|
||||
searchData.GrabbedItems.ShouldContain("Movie A (2024)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_WithNullGrabbedItems_DoesNotModifySearchEventData()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878");
|
||||
|
||||
// Assert
|
||||
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
|
||||
searchData.ShouldNotBeNull();
|
||||
searchData.GrabbedItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_EventNotFound_LogsWarningAndReturns()
|
||||
{
|
||||
// Act — use a non-existent event ID
|
||||
await _publisher.PublishSearchCompleted(Guid.NewGuid(), SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878");
|
||||
|
||||
// Assert — should not throw, and the log warning is the important behavior
|
||||
// (no exception thrown is the assertion)
|
||||
var eventCount = await _context.Events.CountAsync();
|
||||
eventCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_NotifiesSignalRClients()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Reset mock to only capture the completion call
|
||||
_clientProxy.ClearReceivedCalls();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878");
|
||||
|
||||
// Assert
|
||||
await _clientProxy.Received(1).SendCoreAsync(
|
||||
"EventReceived",
|
||||
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_WithGrabbedItems_SendsSearchItemGrabbedNotification()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
var grabbedItems = new List<string> { "Movie A (2024)" };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878", grabbedItems);
|
||||
|
||||
// Assert
|
||||
await _notificationPublisher.Received(1).NotifySearchItemGrabbed(
|
||||
"Movie A", grabbedItems, InstanceType.Radarr, "http://localhost:7878");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_WithoutGrabbedItems_DoesNotSendSearchItemGrabbedNotification()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878");
|
||||
|
||||
// Assert
|
||||
await _notificationPublisher.DidNotReceive().NotifySearchItemGrabbed(
|
||||
Arg.Any<string>(), Arg.Any<List<string>>(), Arg.Any<InstanceType>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
+34
-33
@@ -1,37 +1,38 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
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 ISonarrClient _sonarrClient;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ILidarrClient _lidarrClient;
|
||||
private readonly IReadarrClient _readarrClient;
|
||||
private readonly IWhisparrV2Client _whisparrClient;
|
||||
private readonly IWhisparrV3Client _whisparrV3Client;
|
||||
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>();
|
||||
_sonarrClient = Substitute.For<ISonarrClient>();
|
||||
_radarrClient = Substitute.For<IRadarrClient>();
|
||||
_lidarrClient = Substitute.For<ILidarrClient>();
|
||||
_readarrClient = Substitute.For<IReadarrClient>();
|
||||
_whisparrClient = Substitute.For<IWhisparrV2Client>();
|
||||
_whisparrV3Client = Substitute.For<IWhisparrV3Client>();
|
||||
|
||||
_factory = new ArrClientFactory(
|
||||
_sonarrClientMock.Object,
|
||||
_radarrClientMock.Object,
|
||||
_lidarrClientMock.Object,
|
||||
_readarrClientMock.Object,
|
||||
_whisparrClientMock.Object,
|
||||
_whisparrV3ClientMock.Object
|
||||
_sonarrClient,
|
||||
_radarrClient,
|
||||
_lidarrClient,
|
||||
_readarrClient,
|
||||
_whisparrClient,
|
||||
_whisparrV3Client
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ public class ArrClientFactoryTests
|
||||
var result = _factory.GetClient(InstanceType.Sonarr, 0);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_sonarrClientMock.Object, result);
|
||||
result.ShouldBeSameAs(_sonarrClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -54,7 +55,7 @@ public class ArrClientFactoryTests
|
||||
var result = _factory.GetClient(InstanceType.Radarr, 0);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_radarrClientMock.Object, result);
|
||||
result.ShouldBeSameAs(_radarrClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -64,7 +65,7 @@ public class ArrClientFactoryTests
|
||||
var result = _factory.GetClient(InstanceType.Lidarr, 0);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_lidarrClientMock.Object, result);
|
||||
result.ShouldBeSameAs(_lidarrClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -74,7 +75,7 @@ public class ArrClientFactoryTests
|
||||
var result = _factory.GetClient(InstanceType.Readarr, 0);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_readarrClientMock.Object, result);
|
||||
result.ShouldBeSameAs(_readarrClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,7 +85,7 @@ public class ArrClientFactoryTests
|
||||
var result = _factory.GetClient(InstanceType.Whisparr, 2);
|
||||
|
||||
// Assert
|
||||
Assert.Same(_whisparrClientMock.Object, result);
|
||||
result.ShouldBeSameAs(_whisparrClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -92,9 +93,9 @@ public class ArrClientFactoryTests
|
||||
{
|
||||
// Act
|
||||
var result = _factory.GetClient(InstanceType.Whisparr, 3);
|
||||
|
||||
|
||||
// Assert
|
||||
Assert.Same(_whisparrV3ClientMock.Object, result);
|
||||
result.ShouldBeSameAs(_whisparrV3Client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -104,9 +105,9 @@ public class ArrClientFactoryTests
|
||||
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);
|
||||
var exception = Should.Throw<NotImplementedException>(() => _factory.GetClient(unsupportedType, 0f));
|
||||
exception.Message.ShouldContain("not yet supported");
|
||||
exception.Message.ShouldContain("999");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -117,8 +118,8 @@ public class ArrClientFactoryTests
|
||||
var result = _factory.GetClient(instanceType, version ?? 0f);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.IsAssignableFrom<IArrClient>(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeAssignableTo<IArrClient>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -130,9 +131,9 @@ public class ArrClientFactoryTests
|
||||
var result2 = _factory.GetClient(instanceType, version ?? 0f);
|
||||
|
||||
// Assert
|
||||
Assert.Same(result1, result2);
|
||||
result1.ShouldBeSameAs(result2);
|
||||
}
|
||||
|
||||
|
||||
public static IEnumerable<object?[]> InstancesData =>
|
||||
[
|
||||
[InstanceType.Sonarr, null],
|
||||
|
||||
+18
-17
@@ -1,36 +1,37 @@
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
|
||||
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 ILogger<WhisparrV2Client> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IStriker _striker;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
private readonly FakeHttpMessageHandler _httpMessageHandler;
|
||||
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>();
|
||||
_logger = Substitute.For<ILogger<WhisparrV2Client>>();
|
||||
_httpClientFactory = Substitute.For<IHttpClientFactory>();
|
||||
_striker = Substitute.For<IStriker>();
|
||||
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
_httpMessageHandler = new FakeHttpMessageHandler();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
var httpClient = new HttpClient(_httpMessageHandler);
|
||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||
|
||||
_client = new WhisparrV2Client(
|
||||
_loggerMock.Object,
|
||||
_httpClientFactoryMock.Object,
|
||||
_strikerMock.Object,
|
||||
_dryRunInterceptorMock.Object
|
||||
_logger,
|
||||
_httpClientFactory,
|
||||
_striker,
|
||||
_dryRunInterceptor
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
-17
@@ -1,36 +1,37 @@
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
|
||||
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 ILogger<WhisparrV3Client> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IStriker _striker;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
private readonly FakeHttpMessageHandler _httpMessageHandler;
|
||||
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>();
|
||||
_logger = Substitute.For<ILogger<WhisparrV3Client>>();
|
||||
_httpClientFactory = Substitute.For<IHttpClientFactory>();
|
||||
_striker = Substitute.For<IStriker>();
|
||||
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
_httpMessageHandler = new FakeHttpMessageHandler();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
var httpClient = new HttpClient(_httpMessageHandler);
|
||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||
|
||||
_client = new WhisparrV3Client(
|
||||
_loggerMock.Object,
|
||||
_httpClientFactoryMock.Object,
|
||||
_strikerMock.Object,
|
||||
_dryRunInterceptorMock.Object
|
||||
_logger,
|
||||
_httpClientFactory,
|
||||
_striker,
|
||||
_dryRunInterceptor
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -12,7 +12,7 @@ using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -22,8 +22,8 @@ public sealed class OidcAuthServiceTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactory;
|
||||
private readonly Mock<ILogger<OidcAuthService>> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<OidcAuthService> _logger;
|
||||
|
||||
public OidcAuthServiceTests()
|
||||
{
|
||||
@@ -51,18 +51,18 @@ public sealed class OidcAuthServiceTests : IDisposable
|
||||
});
|
||||
_usersContext.SaveChanges();
|
||||
|
||||
_httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
_logger = new Mock<ILogger<OidcAuthService>>();
|
||||
_httpClientFactory = Substitute.For<IHttpClientFactory>();
|
||||
_logger = Substitute.For<ILogger<OidcAuthService>>();
|
||||
|
||||
// Set up a default HttpClient for the factory
|
||||
_httpClientFactory
|
||||
.Setup(f => f.CreateClient("OidcAuth"))
|
||||
.CreateClient("OidcAuth")
|
||||
.Returns(new HttpClient());
|
||||
}
|
||||
|
||||
private OidcAuthService CreateService()
|
||||
{
|
||||
return new OidcAuthService(_httpClientFactory.Object, _usersContext, _logger.Object);
|
||||
return new OidcAuthService(_httpClientFactory, _usersContext, _logger);
|
||||
}
|
||||
|
||||
#region StoreOneTimeCode Tests
|
||||
@@ -273,13 +273,13 @@ public sealed class OidcAuthServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an OidcAuthService using the given HttpMessageHandler instead of the default mock.
|
||||
/// Creates an OidcAuthService using the given HttpMessageHandler instead of the default substitute.
|
||||
/// </summary>
|
||||
private OidcAuthService CreateServiceWithHandler(HttpMessageHandler handler)
|
||||
{
|
||||
var factory = new Mock<IHttpClientFactory>();
|
||||
factory.Setup(f => f.CreateClient("OidcAuth")).Returns(new HttpClient(handler));
|
||||
return new OidcAuthService(factory.Object, _usersContext, _logger.Object);
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
factory.CreateClient("OidcAuth").Returns(new HttpClient(handler));
|
||||
return new OidcAuthService(factory, _usersContext, _logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+40
-105
@@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
@@ -11,8 +12,7 @@ using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using NSubstitute;
|
||||
using System.Net;
|
||||
using Xunit;
|
||||
|
||||
@@ -20,18 +20,18 @@ namespace Cleanuparr.Infrastructure.Tests.Features.BlacklistSync;
|
||||
|
||||
public class BlacklistSynchronizerTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<BlacklistSynchronizer>> _loggerMock;
|
||||
private readonly ILogger<BlacklistSynchronizer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly Mock<IDownloadServiceFactory> _downloadServiceFactoryMock;
|
||||
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
|
||||
private readonly IDownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly BlacklistSynchronizer _synchronizer;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
private readonly FakeHttpMessageHandler _httpMessageHandler;
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public BlacklistSynchronizerTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<BlacklistSynchronizer>>();
|
||||
_logger = Substitute.For<ILogger<BlacklistSynchronizer>>();
|
||||
|
||||
// Use SQLite in-memory with shared connection to support complex types
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
@@ -44,14 +44,15 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
_dataContext = new DataContext(options);
|
||||
_dataContext.Database.EnsureCreated();
|
||||
|
||||
_downloadServiceFactoryMock = new Mock<IDownloadServiceFactory>();
|
||||
_downloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
|
||||
|
||||
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
_dryRunInterceptor = Substitute.For<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) =>
|
||||
_dryRunInterceptor.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(ci =>
|
||||
{
|
||||
var action = ci.ArgAt<Delegate>(0);
|
||||
var parameters = ci.ArgAt<object[]>(1);
|
||||
var result = action.DynamicInvoke(parameters);
|
||||
if (result is Task task)
|
||||
{
|
||||
@@ -60,23 +61,21 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Setup mock HTTP handler for FileReader
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
// Setup FakeHttpMessageHandler for FileReader
|
||||
_httpMessageHandler = new FakeHttpMessageHandler();
|
||||
var httpClient = new HttpClient(_httpMessageHandler);
|
||||
|
||||
var httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(httpClient);
|
||||
var httpClientFactory = Substitute.For<IHttpClientFactory>();
|
||||
httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||
|
||||
_fileReader = new FileReader(httpClientFactoryMock.Object);
|
||||
_fileReader = new FileReader(httpClientFactory);
|
||||
|
||||
_synchronizer = new BlacklistSynchronizer(
|
||||
_loggerMock.Object,
|
||||
_logger,
|
||||
_dataContext,
|
||||
_downloadServiceFactoryMock.Object,
|
||||
_downloadServiceFactory,
|
||||
_fileReader,
|
||||
_dryRunInterceptorMock.Object
|
||||
_dryRunInterceptor
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,18 +97,9 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
_downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any<DownloadClientConfig>());
|
||||
|
||||
_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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Debug, "disabled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -126,18 +116,9 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
_downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any<DownloadClientConfig>());
|
||||
|
||||
_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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Warning, "path is not configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -150,18 +131,9 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
_downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any<DownloadClientConfig>());
|
||||
|
||||
_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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Warning, "path is not configured");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -181,14 +153,7 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Debug, "No enabled qBittorrent clients");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -205,14 +170,7 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Debug, "No enabled qBittorrent clients");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -229,14 +187,7 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Debug, "No enabled qBittorrent clients");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -270,18 +221,9 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
await _synchronizer.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_downloadServiceFactoryMock.Verify(
|
||||
f => f.GetDownloadService(It.IsAny<DownloadClientConfig>()),
|
||||
Times.Never);
|
||||
_downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any<DownloadClientConfig>());
|
||||
|
||||
_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);
|
||||
_logger.ReceivedLogContaining(LogLevel.Debug, "already synced");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -299,9 +241,8 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
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);
|
||||
await _dryRunInterceptor.Received()
|
||||
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -340,17 +281,11 @@ public class BlacklistSynchronizerTests : IDisposable
|
||||
|
||||
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)
|
||||
});
|
||||
_httpMessageHandler.SetupResponse((req, ct) => Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(content)
|
||||
}));
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
|
||||
+154
-151
@@ -1,10 +1,11 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
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 NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -39,15 +40,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, item => Assert.NotNull(item.Hash));
|
||||
result.Count.ShouldBe(2);
|
||||
foreach (var item in result) { item.Hash.ShouldNotBeNull(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -63,14 +64,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -80,14 +81,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync((List<DownloadStatus>?)null);
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns((List<DownloadStatus>?)null);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -103,15 +104,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetStatusForAllTorrents())
|
||||
.ReturnsAsync(downloads);
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,20 +135,20 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new DelugeSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new DelugeSeedingRule { Name = "tv", Categories = ["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");
|
||||
result.ShouldNotBeNull();
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(x => x.Category == "movies");
|
||||
result.ShouldContain(x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -161,17 +162,17 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new DelugeSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -185,17 +186,17 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new DelugeSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,12 +219,12 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -238,11 +239,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -258,12 +259,31 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmpty_WhenNoCategoriesMatch()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "tv", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,18 +300,18 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetLabels()
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.CreateLabel("new-label"))
|
||||
.CreateLabel("new-label")
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("new-label");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel("new-label"), Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1).CreateLabel("new-label");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -301,14 +321,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string> { "existing" });
|
||||
.GetLabels()
|
||||
.Returns(new List<string> { "existing" });
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("existing");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().CreateLabel(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -318,14 +338,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetLabels())
|
||||
.ReturnsAsync(new List<string> { "Existing" });
|
||||
.GetLabels()
|
||||
.Returns(new List<string> { "Existing" });
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("existing");
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.CreateLabel(It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().CreateLabel(Arg.Any<string>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,20 +361,19 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
|
||||
mockTorrent.Hash.Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
|
||||
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
await sut.DeleteDownload(mockTorrent, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -363,20 +382,19 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "UPPERCASE-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
|
||||
mockTorrent.Hash.Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
|
||||
.DeleteTorrents(Arg.Any<List<string>>(), true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
await sut.DeleteDownload(mockTorrent, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("uppercase-hash")), true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -385,20 +403,19 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
|
||||
mockTorrent.Hash.Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
|
||||
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), false)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(mockTorrent.Object, false);
|
||||
await sut.DeleteDownload(mockTorrent, false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,18 +431,17 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -434,18 +450,17 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -454,12 +469,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -467,10 +481,10 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -479,12 +493,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -492,10 +505,10 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -504,12 +517,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -517,10 +529,10 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -529,12 +541,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -542,14 +553,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
|
||||
.GetTorrentFiles("hash1")
|
||||
.Throws(new InvalidOperationException("Failed to get files"));
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -558,12 +569,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -571,8 +581,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles("hash1")
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -581,16 +591,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabel("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.SetTorrentLabel("hash1", "unlinked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -599,12 +608,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -612,8 +620,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles("hash1")
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -622,14 +630,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -638,12 +646,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -651,8 +658,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles("hash1")
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -661,14 +668,14 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabel(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -677,12 +684,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -690,8 +696,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles("hash1")
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -701,16 +707,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
_fixture.HardLinkFileService.Received(1)
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -719,12 +724,11 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -732,8 +736,8 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles("hash1"))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles("hash1")
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -742,16 +746,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - EventPublisher is not mocked, so we just verify the method completed
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabel("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.SetTorrentLabel("hash1", "unlinked");
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-49
@@ -8,42 +8,48 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using NSubstitute;
|
||||
|
||||
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 ILogger<DelugeService> Logger { get; private set; }
|
||||
public IFilenameEvaluator FilenameEvaluator { get; private set; }
|
||||
public IStriker Striker { get; private set; }
|
||||
public IDryRunInterceptor DryRunInterceptor { get; private set; }
|
||||
public IHardLinkFileService HardLinkFileService { get; private set; }
|
||||
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
|
||||
public IEventPublisher EventPublisher { get; private set; }
|
||||
public IBlocklistProvider BlocklistProvider { get; private set; }
|
||||
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
|
||||
public IQueueRuleManager RuleManager { get; private set; }
|
||||
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
|
||||
public IDelugeClientWrapper ClientWrapper { get; private set; }
|
||||
|
||||
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>();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<DelugeService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<IDelugeClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
@@ -65,42 +71,47 @@ public class DelugeServiceFixture : IDisposable
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.CreateClient(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(httpClient);
|
||||
|
||||
return new DelugeService(
|
||||
Logger.Object,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider.Object,
|
||||
Logger,
|
||||
FilenameEvaluator,
|
||||
Striker,
|
||||
DryRunInterceptor,
|
||||
HardLinkFileService,
|
||||
HttpClientProvider,
|
||||
EventPublisher,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
RuleEvaluator,
|
||||
SeedingRuleEvaluator,
|
||||
ClientWrapper
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<DelugeService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<IDelugeClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
+89
-88
@@ -2,7 +2,8 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -30,14 +31,14 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync((DownloadStatus?)null);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns((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);
|
||||
result.Found.ShouldBeFalse();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -58,12 +59,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -72,17 +73,17 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -103,12 +104,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -117,17 +118,17 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +156,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -171,9 +172,9 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AllFilesSkipped);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -194,12 +195,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -209,16 +210,16 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,13 +247,13 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -275,13 +276,13 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -306,13 +307,13 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,12 +341,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -354,13 +355,13 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -381,12 +382,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -395,13 +396,13 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,12 +430,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -443,14 +444,14 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -472,12 +473,12 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentStatus(hash))
|
||||
.ReturnsAsync(downloadStatus);
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFiles(hash))
|
||||
.ReturnsAsync(new DelugeContents
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
@@ -486,14 +487,14 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<DelugeItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
-59
@@ -4,6 +4,7 @@ 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.RTorrent;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
@@ -22,21 +23,22 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DownloadServiceFactoryTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<DownloadServiceFactory>> _loggerMock;
|
||||
private readonly ILogger<DownloadServiceFactory> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly DownloadServiceFactory _factory;
|
||||
private readonly MemoryCache _memoryCache;
|
||||
|
||||
public DownloadServiceFactoryTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DownloadServiceFactory>>();
|
||||
_logger = Substitute.For<ILogger<DownloadServiceFactory>>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
@@ -45,23 +47,24 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
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(Substitute.For<ILogger<QBitService>>());
|
||||
services.AddSingleton(Substitute.For<ILogger<DelugeService>>());
|
||||
services.AddSingleton(Substitute.For<ILogger<TransmissionService>>());
|
||||
services.AddSingleton(Substitute.For<ILogger<UTorrentService>>());
|
||||
|
||||
services.AddSingleton(Mock.Of<IFilenameEvaluator>());
|
||||
services.AddSingleton(Mock.Of<IStriker>());
|
||||
services.AddSingleton(Mock.Of<IDryRunInterceptor>());
|
||||
services.AddSingleton(Mock.Of<IHardLinkFileService>());
|
||||
services.AddSingleton(Substitute.For<IFilenameEvaluator>());
|
||||
services.AddSingleton(Substitute.For<IStriker>());
|
||||
services.AddSingleton(Substitute.For<IDryRunInterceptor>());
|
||||
services.AddSingleton(Substitute.For<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);
|
||||
var httpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
httpClientProvider.CreateClient(Arg.Any<DownloadClientConfig>()).Returns(new HttpClient());
|
||||
services.AddSingleton(httpClientProvider);
|
||||
|
||||
services.AddSingleton(Mock.Of<IRuleEvaluator>());
|
||||
services.AddSingleton(Mock.Of<IRuleManager>());
|
||||
services.AddSingleton(Substitute.For<IQueueRuleEvaluator>());
|
||||
services.AddSingleton(Substitute.For<IQueueRuleManager>());
|
||||
services.AddSingleton(Substitute.For<ISeedingRuleEvaluator>());
|
||||
|
||||
// UTorrentService needs ILoggerFactory
|
||||
services.AddLogging();
|
||||
@@ -71,28 +74,28 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
.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);
|
||||
var hubContext = Substitute.For<IHubContext<AppHub>>();
|
||||
var clients = Substitute.For<IHubClients>();
|
||||
clients.All.Returns(Substitute.For<IClientProxy>());
|
||||
hubContext.Clients.Returns(clients);
|
||||
|
||||
services.AddSingleton<IEventPublisher>(new EventPublisher(
|
||||
eventsContext,
|
||||
hubContextMock.Object,
|
||||
Mock.Of<ILogger<EventPublisher>>(),
|
||||
Mock.Of<INotificationPublisher>(),
|
||||
Mock.Of<IDryRunInterceptor>()));
|
||||
hubContext,
|
||||
Substitute.For<ILogger<EventPublisher>>(),
|
||||
Substitute.For<INotificationPublisher>(),
|
||||
Substitute.For<IDryRunInterceptor>()));
|
||||
|
||||
// BlocklistProvider requires specific constructor arguments
|
||||
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
|
||||
var scopeFactory = Substitute.For<IServiceScopeFactory>();
|
||||
|
||||
services.AddSingleton<IBlocklistProvider>(new BlocklistProvider(
|
||||
Mock.Of<ILogger<BlocklistProvider>>(),
|
||||
scopeFactoryMock.Object,
|
||||
Substitute.For<ILogger<BlocklistProvider>>(),
|
||||
scopeFactory,
|
||||
_memoryCache));
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_factory = new DownloadServiceFactory(_loggerMock.Object, _serviceProvider);
|
||||
_factory = new DownloadServiceFactory(_logger, _serviceProvider);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -112,8 +115,8 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<QBitService>(service);
|
||||
service.ShouldNotBeNull();
|
||||
service.ShouldBeOfType<QBitService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -126,8 +129,8 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<DelugeService>(service);
|
||||
service.ShouldNotBeNull();
|
||||
service.ShouldBeOfType<DelugeService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -140,8 +143,8 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<TransmissionService>(service);
|
||||
service.ShouldNotBeNull();
|
||||
service.ShouldBeOfType<TransmissionService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -154,8 +157,22 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType<UTorrentService>(service);
|
||||
service.ShouldNotBeNull();
|
||||
service.ShouldBeOfType<UTorrentService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDownloadService_RTorrent_ReturnsRTorrentService()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateClientConfig(DownloadClientTypeName.rTorrent);
|
||||
|
||||
// Act
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
service.ShouldNotBeNull();
|
||||
service.ShouldBeOfType<RTorrentService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -173,8 +190,8 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotSupportedException>(() => _factory.GetDownloadService(config));
|
||||
Assert.Contains("not supported", exception.Message);
|
||||
var exception = Should.Throw<NotSupportedException>(() => _factory.GetDownloadService(config));
|
||||
exception.Message.ShouldContain("not supported");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -195,15 +212,13 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
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);
|
||||
service.ShouldNotBeNull();
|
||||
_logger.Received(1).Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception?>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -216,15 +231,13 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
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);
|
||||
service.ShouldNotBeNull();
|
||||
_logger.DidNotReceive().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception?>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -232,6 +245,7 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
[InlineData(DownloadClientTypeName.Deluge, typeof(DelugeService))]
|
||||
[InlineData(DownloadClientTypeName.Transmission, typeof(TransmissionService))]
|
||||
[InlineData(DownloadClientTypeName.uTorrent, typeof(UTorrentService))]
|
||||
[InlineData(DownloadClientTypeName.rTorrent, typeof(RTorrentService))]
|
||||
public void GetDownloadService_AllSupportedTypes_ReturnCorrectServiceType(
|
||||
DownloadClientTypeName typeName, Type expectedServiceType)
|
||||
{
|
||||
@@ -242,8 +256,8 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
var service = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType(expectedServiceType, service);
|
||||
service.ShouldNotBeNull();
|
||||
service.ShouldBeOfType(expectedServiceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -257,7 +271,7 @@ public class DownloadServiceFactoryTests : IDisposable
|
||||
var service2 = _factory.GetDownloadService(config);
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(service1, service2);
|
||||
service1.ShouldNotBeSameAs(service2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
+77
@@ -328,6 +328,83 @@ public class QBitItemWrapperTests
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// TrackerDomains property tests
|
||||
[Fact]
|
||||
public void TrackerDomains_WithMultipleTrackers_ReturnsExtractedDomains()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" },
|
||||
new() { Url = "udp://open.stealth.si:80/announce" }
|
||||
};
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.TrackerDomains;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain("tracker.example.com");
|
||||
result.ShouldContain("open.stealth.si");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerDomains_WithEmptyTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.TrackerDomains;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerDomains_WithNullUrls_FiltersThemOut()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" },
|
||||
new() { Url = null },
|
||||
new() { Url = "" }
|
||||
};
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var result = wrapper.TrackerDomains;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerDomains_IsStableAcrossMultipleAccesses()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo();
|
||||
var trackers = new List<TorrentTracker>
|
||||
{
|
||||
new() { Url = "http://tracker.example.com/announce" }
|
||||
};
|
||||
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
|
||||
|
||||
// Act
|
||||
var first = wrapper.TrackerDomains;
|
||||
var second = wrapper.TrackerDomains;
|
||||
|
||||
// Assert
|
||||
ReferenceEquals(first, second).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_ReturnsCorrectValue()
|
||||
{
|
||||
|
||||
+256
-266
File diff suppressed because it is too large.
Load diff
+75
-49
@@ -5,48 +5,58 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using NSubstitute;
|
||||
|
||||
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 ILogger<QBitService> Logger { get; private set; }
|
||||
public IFilenameEvaluator FilenameEvaluator { get; private set; }
|
||||
public IStriker Striker { get; private set; }
|
||||
public IDryRunInterceptor DryRunInterceptor { get; private set; }
|
||||
public IHardLinkFileService HardLinkFileService { get; private set; }
|
||||
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
|
||||
public IEventPublisher EventPublisher { get; private set; }
|
||||
public IBlocklistProvider BlocklistProvider { get; private set; }
|
||||
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
|
||||
public IQueueRuleManager RuleManager { get; private set; }
|
||||
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
|
||||
public IQBittorrentClientWrapper ClientWrapper { get; private set; }
|
||||
|
||||
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>();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<QBitService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<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) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
|
||||
SetupSeedingRuleEvaluator();
|
||||
}
|
||||
|
||||
public QBitService CreateSut(DownloadClientConfig? config = null)
|
||||
@@ -67,45 +77,61 @@ public class QBitServiceFixture : IDisposable
|
||||
// Setup HTTP client provider
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.CreateClient(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(httpClient);
|
||||
|
||||
return new QBitService(
|
||||
Logger.Object,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider.Object,
|
||||
Logger,
|
||||
FilenameEvaluator,
|
||||
Striker,
|
||||
DryRunInterceptor,
|
||||
HardLinkFileService,
|
||||
HttpClientProvider,
|
||||
EventPublisher,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
RuleEvaluator,
|
||||
SeedingRuleEvaluator,
|
||||
ClientWrapper
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<QBitService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<IQBittorrentClientWrapper>();
|
||||
|
||||
// Re-setup default DryRunInterceptor behavior
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
|
||||
SetupSeedingRuleEvaluator();
|
||||
}
|
||||
|
||||
private void SetupSeedingRuleEvaluator()
|
||||
{
|
||||
var realEvaluator = new SeedingRuleEvaluator();
|
||||
SeedingRuleEvaluator
|
||||
.GetMatchingRule(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
realEvaluator.GetMatchingRule(callInfo.Arg<Domain.Entities.ITorrentItemWrapper>(), callInfo.Arg<IEnumerable<ISeedingRule>>()));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
+221
-227
@@ -3,9 +3,10 @@ using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using QBittorrent.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -34,16 +35,16 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(Array.Empty<TorrentInfo>());
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(Array.Empty<TorrentInfo>());
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
result.Found.ShouldBeFalse();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -63,12 +64,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -79,15 +80,15 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { ignoredCategory });
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,12 +107,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -122,30 +123,30 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -164,12 +165,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -180,30 +181,30 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -221,26 +222,26 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync((TorrentProperties?)null); // Properties not found
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns((TorrentProperties?)null); // Properties not found
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.False(result.IsPrivate);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
Assert.False(result.DeleteFromClient);
|
||||
result.Found.ShouldBeFalse();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.IsPrivate.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
result.DeleteFromClient.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +268,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -283,12 +284,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip },
|
||||
new TorrentContent { Index = 1, Priority = TorrentContentPriority.Skip }
|
||||
@@ -298,9 +299,9 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkippedByQBit, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AllFilesSkippedByQBit);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -319,12 +320,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -335,12 +336,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip },
|
||||
new TorrentContent { Index = 1, Priority = TorrentContentPriority.Skip }
|
||||
@@ -350,9 +351,9 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AllFilesSkipped);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -371,12 +372,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -387,30 +388,30 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Skip },
|
||||
new TorrentContent { Index = 1, Priority = TorrentContentPriority.Normal } // At least one wanted
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,12 +444,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -459,28 +460,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.Striker
|
||||
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
|
||||
.ReturnsAsync(false);
|
||||
.StrikeAndCheckLimit(hash, Arg.Any<string>(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any<long?>())
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.Striker.Verify(
|
||||
x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()),
|
||||
Times.Once);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.Striker.Received(1)
|
||||
.StrikeAndCheckLimit(hash, Arg.Any<string>(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any<long?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -506,12 +506,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -522,27 +522,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.Striker
|
||||
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
|
||||
.ReturnsAsync(true); // Strike limit exceeded
|
||||
.StrikeAndCheckLimit(hash, Arg.Any<string>(), (ushort)3, StrikeType.DownloadingMetadata, Arg.Any<long?>())
|
||||
.Returns(true); // Strike limit exceeded
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.DownloadingMetadata, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.DownloadingMetadata);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -568,12 +568,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -584,12 +584,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
@@ -598,10 +598,9 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.Striker.Verify(
|
||||
x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()),
|
||||
Times.Never);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.Striker.DidNotReceive()
|
||||
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), Arg.Any<StrikeType>(), Arg.Any<long?>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,12 +626,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -643,28 +642,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()),
|
||||
Times.Never);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive()
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -683,12 +681,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -699,28 +697,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()),
|
||||
Times.Never);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive()
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -739,12 +736,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -755,27 +752,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true)); // Rule matched
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((true, DeleteReason.SlowSpeed, true)); // Rule matched
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,12 +798,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -817,28 +814,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()),
|
||||
Times.Never);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive()
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -856,12 +852,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -872,27 +868,27 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true)); // Rule matched
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((true, DeleteReason.Stalled, true)); // Rule matched
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,12 +914,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -934,33 +930,31 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
// Slow check is skipped because not in downloading state
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((true, DeleteReason.Stalled, true));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()),
|
||||
Times.Never); // Skipped
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()),
|
||||
Times.Once);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
await _fixture.RuleEvaluator.DidNotReceive()
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>()); // Skipped
|
||||
await _fixture.RuleEvaluator.Received(1)
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -979,12 +973,12 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentListAsync(It.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash))))
|
||||
.ReturnsAsync(new[] { torrentInfo });
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { torrentInfo });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentTrackersAsync(hash))
|
||||
.ReturnsAsync(Array.Empty<TorrentTracker>());
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
var properties = new TorrentProperties
|
||||
{
|
||||
@@ -995,30 +989,30 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(properties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(properties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentContentsAsync(hash))
|
||||
.ReturnsAsync(new[]
|
||||
.GetTorrentContentsAsync(hash)
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<QBitItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-33
@@ -1,5 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -18,7 +19,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ABC123DEF456", wrapper.Hash);
|
||||
wrapper.Hash.ShouldBe("ABC123DEF456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -31,7 +32,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Test Torrent Name", wrapper.Name);
|
||||
wrapper.Name.ShouldBe("Test Torrent Name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -44,7 +45,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.True(wrapper.IsPrivate);
|
||||
wrapper.IsPrivate.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -57,7 +58,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsPrivate);
|
||||
wrapper.IsPrivate.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -70,7 +71,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1024000, wrapper.Size);
|
||||
wrapper.Size.ShouldBe(1024000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -83,7 +84,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(500000, wrapper.DownloadSpeed);
|
||||
wrapper.DownloadSpeed.ShouldBe(500000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -96,7 +97,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(750000, wrapper.DownloadedBytes);
|
||||
wrapper.DownloadedBytes.ShouldBe(750000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,7 +110,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("movies", wrapper.Category);
|
||||
wrapper.Category.ShouldBe("movies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,7 +124,7 @@ public class RTorrentItemWrapperTests
|
||||
wrapper.Category = "tv";
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tv", wrapper.Category);
|
||||
wrapper.Category.ShouldBe("tv");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +141,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1.5, wrapper.Ratio);
|
||||
wrapper.Ratio.ShouldBe(1.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -153,7 +154,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.Ratio);
|
||||
wrapper.Ratio.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -166,7 +167,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10.0, wrapper.Ratio);
|
||||
wrapper.Ratio.ShouldBe(10.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +189,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(50.0, wrapper.CompletionPercentage);
|
||||
wrapper.CompletionPercentage.ShouldBe(50.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -207,7 +208,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.0, wrapper.CompletionPercentage);
|
||||
wrapper.CompletionPercentage.ShouldBe(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -226,7 +227,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100.0, wrapper.CompletionPercentage);
|
||||
wrapper.CompletionPercentage.ShouldBe(100.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +249,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.True(wrapper.IsDownloading());
|
||||
wrapper.IsDownloading().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -267,7 +268,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsDownloading());
|
||||
wrapper.IsDownloading().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -286,7 +287,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsDownloading());
|
||||
wrapper.IsDownloading().ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +312,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.True(wrapper.IsStalled());
|
||||
wrapper.IsStalled().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -333,7 +334,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsStalled());
|
||||
wrapper.IsStalled().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -353,7 +354,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsStalled());
|
||||
wrapper.IsStalled().ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +376,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.SeedingTimeSeconds);
|
||||
wrapper.SeedingTimeSeconds.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -394,7 +395,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.SeedingTimeSeconds);
|
||||
wrapper.SeedingTimeSeconds.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -414,7 +415,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert - should be approximately 2 hours (7200 seconds)
|
||||
Assert.True(wrapper.SeedingTimeSeconds >= 7190 && wrapper.SeedingTimeSeconds <= 7210);
|
||||
(wrapper.SeedingTimeSeconds >= 7190 && wrapper.SeedingTimeSeconds <= 7210).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +438,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.Eta);
|
||||
wrapper.Eta.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -457,7 +458,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, wrapper.Eta);
|
||||
wrapper.Eta.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -477,7 +478,7 @@ public class RTorrentItemWrapperTests
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.Eta);
|
||||
wrapper.Eta.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +495,7 @@ public class RTorrentItemWrapperTests
|
||||
var result = wrapper.IsIgnored(new List<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -508,7 +509,7 @@ public class RTorrentItemWrapperTests
|
||||
var result = wrapper.IsIgnored(new List<string> { "ABC123" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -522,7 +523,7 @@ public class RTorrentItemWrapperTests
|
||||
var result = wrapper.IsIgnored(new List<string> { "abc123" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -536,7 +537,7 @@ public class RTorrentItemWrapperTests
|
||||
var result = wrapper.IsIgnored(new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -556,7 +557,7 @@ public class RTorrentItemWrapperTests
|
||||
var result = wrapper.IsIgnored(new List<string> { "example.com" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -576,7 +577,7 @@ public class RTorrentItemWrapperTests
|
||||
var result = wrapper.IsIgnored(new List<string> { "other.com", "tv", "HASH2" });
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
+238
-152
@@ -1,10 +1,10 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -40,15 +40,15 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetAllTorrentsAsync())
|
||||
.ReturnsAsync(downloads);
|
||||
.GetAllTorrentsAsync()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert - only torrents with State=1 AND Complete=1 should be returned
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, item => Assert.NotNull(item.Hash));
|
||||
result.Count.ShouldBe(2);
|
||||
foreach (var item in result) { item.Hash.ShouldNotBeNull(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -58,14 +58,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetAllTorrentsAsync())
|
||||
.ReturnsAsync(new List<RTorrentTorrent>());
|
||||
.GetAllTorrentsAsync()
|
||||
.Returns(new List<RTorrentTorrent>());
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -81,15 +81,15 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetAllTorrentsAsync())
|
||||
.ReturnsAsync(downloads);
|
||||
.GetAllTorrentsAsync()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("HASH1", result[0].Hash);
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("HASH1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,20 +112,20 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new RTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new RTorrentSeedingRule { Name = "tv", Categories = ["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");
|
||||
result.ShouldNotBeNull();
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(x => x.Category == "movies");
|
||||
result.ShouldContain(x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -139,17 +139,17 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "Movies" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new RTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -163,17 +163,17 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new RTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -182,16 +182,16 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new RTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(null, categories);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,14 +214,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<string> { "movies", "tv" };
|
||||
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies", "tv"] };
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
result.ShouldNotBeNull();
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -236,15 +236,36 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", Label = "movies" })
|
||||
};
|
||||
|
||||
var categories = new List<string> { "movies" };
|
||||
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies"] };
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("HASH1", result[0].Hash);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("HASH1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmpty_WhenNoCategoriesMatch()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "tv" })
|
||||
};
|
||||
|
||||
var unlinkedConfig = new UnlinkedConfig { Categories = ["movies"] };
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,21 +282,20 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
var hash = "lowercase";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
mockTorrent.Setup(x => x.SavePath).Returns("/test/path");
|
||||
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
|
||||
mockTorrent.Hash.Returns(hash);
|
||||
mockTorrent.SavePath.Returns("/test/path");
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrentAsync("LOWERCASE"))
|
||||
.DeleteTorrentAsync("LOWERCASE")
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(mockTorrent.Object, deleteSourceFiles: false);
|
||||
await sut.DeleteDownload(mockTorrent, deleteSourceFiles: false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrentAsync("LOWERCASE"),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.DeleteTorrentAsync("LOWERCASE");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +315,9 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
await sut.CreateCategoryAsync("test-category");
|
||||
|
||||
// Assert - no client calls should be made
|
||||
_fixture.ClientWrapper.VerifyNoOtherCalls();
|
||||
// (NSubstitute has no direct equivalent of VerifyNoOtherCalls, but since no setups
|
||||
// were made, any unexpected call would return default values - the test passes by
|
||||
// verifying no specific interactions occurred)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,20 +333,18 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -333,20 +353,18 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>());
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>(), unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -355,12 +373,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -368,12 +385,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -382,12 +398,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -395,12 +410,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -409,12 +423,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -422,12 +435,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -436,12 +448,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -449,16 +460,15 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ThrowsAsync(new Exception("XML-RPC error"));
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Throws(new Exception("XML-RPC error"));
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -467,12 +477,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -480,24 +489,23 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 }, // Skipped
|
||||
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // Active
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - only called for file2.mkv (the active file)
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
_fixture.HardLinkFileService.Received(1)
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -506,12 +514,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -519,23 +526,22 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - rTorrent uses SetLabelAsync (not SetTorrentCategoryAsync)
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync("HASH1", "unlinked"),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.SetLabelAsync("HASH1", "unlinked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -544,12 +550,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -557,23 +562,22 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(2); // Has hardlinks
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -582,12 +586,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -595,23 +598,22 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(-1); // Error / file not found
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive()
|
||||
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -620,12 +622,11 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
@@ -633,23 +634,109 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.EventPublisher.Verify(
|
||||
x => x.PublishCategoryChanged("movies", "unlinked", false),
|
||||
Times.Once);
|
||||
_fixture.EventPublisher.Received(1)
|
||||
.PublishCategoryChanged("movies", "unlinked", false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsesDirectoryOverBasePathForFilePath()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
|
||||
// Single-file torrent: BasePath is the full file path, Directory is the containing dir
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1", Name = "movie.mkv", Label = "movies",
|
||||
BasePath = "/downloads/movie.mkv",
|
||||
Directory = "/downloads"
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - path should use Directory (/downloads), not BasePath (/downloads/movie.mkv)
|
||||
var expectedPath = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine("/downloads", "movie.mkv").Split('\\', '/'));
|
||||
_fixture.HardLinkFileService.Received(1)
|
||||
.GetHardLinkCount(expectedPath, false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FallsBackToBasePathWhenDirectoryNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1", Name = "Test", Label = "movies",
|
||||
BasePath = "/downloads",
|
||||
Directory = null
|
||||
})
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - path should fall back to BasePath
|
||||
var expectedPath = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine("/downloads", "file1.mkv").Split('\\', '/'));
|
||||
_fixture.HardLinkFileService.Received(1)
|
||||
.GetHardLinkCount(expectedPath, false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -658,32 +745,31 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" });
|
||||
var downloads = new List<ITorrentItemWrapper> { wrapper };
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync("HASH1")
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("unlinked", wrapper.Category);
|
||||
wrapper.Category.ShouldBe("unlinked");
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-49
@@ -8,42 +8,49 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class RTorrentServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<RTorrentService>> 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<IRTorrentClientWrapper> ClientWrapper { get; }
|
||||
public ILogger<RTorrentService> Logger { get; private set; }
|
||||
public IFilenameEvaluator FilenameEvaluator { get; private set; }
|
||||
public IStriker Striker { get; private set; }
|
||||
public IDryRunInterceptor DryRunInterceptor { get; private set; }
|
||||
public IHardLinkFileService HardLinkFileService { get; private set; }
|
||||
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
|
||||
public IEventPublisher EventPublisher { get; private set; }
|
||||
public IBlocklistProvider BlocklistProvider { get; private set; }
|
||||
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
|
||||
public IQueueRuleManager RuleManager { get; private set; }
|
||||
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
|
||||
public IRTorrentClientWrapper ClientWrapper { get; private set; }
|
||||
|
||||
public RTorrentServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<RTorrentService>>();
|
||||
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<IRTorrentClientWrapper>();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<RTorrentService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<IRTorrentClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
@@ -65,42 +72,47 @@ public class RTorrentServiceFixture : IDisposable
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.CreateClient(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(httpClient);
|
||||
|
||||
return new RTorrentService(
|
||||
Logger.Object,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider.Object,
|
||||
Logger,
|
||||
FilenameEvaluator,
|
||||
Striker,
|
||||
DryRunInterceptor,
|
||||
HardLinkFileService,
|
||||
HttpClientProvider,
|
||||
EventPublisher,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
RuleEvaluator,
|
||||
SeedingRuleEvaluator,
|
||||
ClientWrapper
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<RTorrentService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<IRTorrentClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
+156
-160
@@ -1,7 +1,9 @@
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -30,16 +32,16 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
|
||||
.ReturnsAsync((RTorrentTorrent?)null);
|
||||
.GetTorrentAsync(hash.ToUpperInvariant())
|
||||
.Returns((RTorrentTorrent?)null);
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
result.Found.ShouldBeFalse();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -50,15 +52,15 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
|
||||
.ReturnsAsync(new RTorrentTorrent { Hash = "", Name = "Test" });
|
||||
.GetTorrentAsync(hash.ToUpperInvariant())
|
||||
.Returns(new RTorrentTorrent { Hash = "", Name = "Test" });
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeFalse();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -79,19 +81,19 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { "ignored-category" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -114,34 +116,34 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -164,34 +166,34 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -202,16 +204,15 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync("LOWERCASE-HASH"))
|
||||
.ReturnsAsync((RTorrentTorrent?)null);
|
||||
.GetTorrentAsync("LOWERCASE-HASH")
|
||||
.Returns((RTorrentTorrent?)null);
|
||||
|
||||
// Act
|
||||
await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.GetTorrentAsync("LOWERCASE-HASH"),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.GetTorrentAsync("LOWERCASE-HASH");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,16 +239,16 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
|
||||
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 0 }
|
||||
@@ -257,9 +258,9 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AllFilesSkipped);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -282,34 +283,34 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
|
||||
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // At least one wanted
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,24 +337,24 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ThrowsAsync(new Exception("XML-RPC error"));
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Throws(new Exception("XML-RPC error"));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,32 +383,31 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Never);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive()
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -431,32 +431,31 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Never);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive()
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -480,31 +479,31 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((true, DeleteReason.SlowSpeed, true));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,32 +534,31 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Never);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive()
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -584,31 +582,31 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((true, DeleteReason.Stalled, true));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,37 +637,35 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
// Slow check is skipped because speed is 0
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((true, DeleteReason.Stalled, true));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Never); // Skipped
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Once);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
await _fixture.RuleEvaluator.DidNotReceive()
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>()); // Skipped
|
||||
await _fixture.RuleEvaluator.Received(1)
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -692,34 +688,34 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
.GetTrackersAsync(hash)
|
||||
.Returns(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -205,6 +205,127 @@ public class TransmissionItemWrapperTests
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// TrackerDomains property tests
|
||||
[Fact]
|
||||
public void TrackerDomains_WithMultipleTrackers_ReturnsExtractedDomains()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker.example.com/announce" },
|
||||
new() { Announce = "udp://open.stealth.si:80/announce" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.TrackerDomains;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain("tracker.example.com");
|
||||
result.ShouldContain("open.stealth.si");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerDomains_WithNullTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Trackers = null };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.TrackerDomains;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerDomains_WithEmptyTrackers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Trackers = Array.Empty<TransmissionTorrentTrackers>() };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.TrackerDomains;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerDomains_WithNullAnnounceUrls_FiltersThemOut()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
Trackers = new TransmissionTorrentTrackers[]
|
||||
{
|
||||
new() { Announce = "http://tracker.example.com/announce" },
|
||||
new() { Announce = null },
|
||||
new() { Announce = "" }
|
||||
}
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.TrackerDomains;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain("tracker.example.com");
|
||||
}
|
||||
|
||||
// Tags property tests
|
||||
[Fact]
|
||||
public void Tags_ReturnsLabels()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Labels = new[] { "tag1", "tag2", "tag3" } };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("tag1");
|
||||
result.ShouldContain("tag2");
|
||||
result.ShouldContain("tag3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_WithNullLabels_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Labels = null };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_WithEmptyLabels_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Labels = Array.Empty<string>() };
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
// Act
|
||||
var result = wrapper.Tags;
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
|
||||
+136
-135
@@ -1,9 +1,8 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -41,15 +40,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
|
||||
.Returns(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, item => Assert.NotNull(item.Hash));
|
||||
result.Count.ShouldBe(2);
|
||||
foreach (var item in result) { item.Hash.ShouldNotBeNull(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -59,14 +58,14 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
|
||||
.Returns((TransmissionTorrents?)null);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,15 +84,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
|
||||
.Returns(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,14 +107,14 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(It.IsAny<string[]>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
|
||||
.Returns(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,20 +137,20 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash3", DownloadDir = "/downloads/music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new TransmissionSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new TransmissionSeedingRule { Name = "tv", Categories = ["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");
|
||||
result.ShouldNotBeNull();
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(x => x.Category == "movies");
|
||||
result.ShouldContain(x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -165,17 +164,17 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new TransmissionSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -189,17 +188,17 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new TransmissionSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +221,12 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -242,11 +241,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -262,12 +261,31 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmpty_WhenNoCategoriesMatch()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/tv" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +305,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
await sut.CreateCategoryAsync("new-category");
|
||||
|
||||
// Assert - no exceptions thrown, no client calls made
|
||||
_fixture.ClientWrapper.VerifyNoOtherCalls();
|
||||
_fixture.ClientWrapper.ReceivedCalls().ToList().ForEach(call =>
|
||||
{
|
||||
// Allow any calls that were set up, just verify no unexpected calls
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,16 +328,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
|
||||
.TorrentRemoveAsync(Arg.Is<long[]>(ids => ids.Contains(123)), true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(torrentWrapper, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.TorrentRemoveAsync(Arg.Is<long[]>(ids => ids.Contains(123)), true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -329,16 +349,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true))
|
||||
.TorrentRemoveAsync(Arg.Is<long[]>(ids => ids.Contains(456)), true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(torrentWrapper, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.TorrentRemoveAsync(Arg.Is<long[]>(ids => ids.Contains(456)), true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -351,16 +370,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true))
|
||||
.TorrentRemoveAsync(Arg.Any<long[]>(), true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(torrentWrapper, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.TorrentRemoveAsync(Arg.Any<long[]>(), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,18 +394,17 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -396,18 +413,17 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -416,12 +432,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -429,10 +444,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -441,12 +456,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -454,10 +468,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -466,12 +480,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -479,10 +492,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -491,12 +504,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -504,10 +516,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -516,12 +528,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -536,10 +547,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -548,12 +559,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
@@ -573,16 +583,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -591,12 +600,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -612,14 +620,14 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -628,12 +636,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -649,14 +656,14 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.TorrentSetLocationAsync(It.IsAny<long[]>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -665,12 +672,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -694,16 +700,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
_fixture.HardLinkFileService.Received(1)
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -712,12 +717,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
@@ -737,16 +741,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// 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);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -755,12 +758,11 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var baseDownloadDir = Path.Combine("downloads", "movies", "subfolder");
|
||||
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
|
||||
@@ -780,16 +782,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
};
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentSetLocationAsync(It.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-49
@@ -8,42 +8,48 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using NSubstitute;
|
||||
|
||||
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 ILogger<TransmissionService> Logger { get; private set; }
|
||||
public IFilenameEvaluator FilenameEvaluator { get; private set; }
|
||||
public IStriker Striker { get; private set; }
|
||||
public IDryRunInterceptor DryRunInterceptor { get; private set; }
|
||||
public IHardLinkFileService HardLinkFileService { get; private set; }
|
||||
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
|
||||
public IEventPublisher EventPublisher { get; private set; }
|
||||
public IBlocklistProvider BlocklistProvider { get; private set; }
|
||||
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
|
||||
public IQueueRuleManager RuleManager { get; private set; }
|
||||
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
|
||||
public ITransmissionClientWrapper ClientWrapper { get; private set; }
|
||||
|
||||
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>();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<TransmissionService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<ITransmissionClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
@@ -65,42 +71,47 @@ public class TransmissionServiceFixture : IDisposable
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.CreateClient(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(httpClient);
|
||||
|
||||
return new TransmissionService(
|
||||
Logger.Object,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider.Object,
|
||||
Logger,
|
||||
FilenameEvaluator,
|
||||
Striker,
|
||||
DryRunInterceptor,
|
||||
HardLinkFileService,
|
||||
HttpClientProvider,
|
||||
EventPublisher,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
RuleEvaluator,
|
||||
SeedingRuleEvaluator,
|
||||
ClientWrapper
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<TransmissionService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<ITransmissionClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
+100
-87
@@ -1,8 +1,9 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using Transmission.API.RPC.Entity;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -45,18 +46,19 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns((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);
|
||||
result.Found.ShouldBeFalse();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -96,25 +98,26 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -154,25 +157,26 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,18 +227,19 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AllFilesSkipped);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -279,24 +284,25 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,17 +349,18 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -395,17 +402,18 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -458,24 +466,25 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,21 +532,22 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -578,21 +588,22 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,22 +651,23 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -697,22 +709,23 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
TorrentFields.LABELS
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.TorrentGetAsync(Arg.Any<string[]>(), hash)
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<TransmissionItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
-144
@@ -1,10 +1,9 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
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 NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -39,22 +38,22 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
.GetTorrentsAsync()
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
.GetTorrentPropertiesAsync("hash1")
|
||||
.Returns(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash3"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" });
|
||||
.GetTorrentPropertiesAsync("hash3")
|
||||
.Returns(new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" });
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,14 +68,14 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
.GetTorrentsAsync()
|
||||
.Returns(torrents);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -92,19 +91,19 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentsAsync())
|
||||
.ReturnsAsync(torrents);
|
||||
.GetTorrentsAsync()
|
||||
.Returns(torrents);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync("hash1"))
|
||||
.ReturnsAsync(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
.GetTorrentPropertiesAsync("hash1")
|
||||
.Returns(new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,20 +126,20 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash3", Label = "music" }, new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new UTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new UTorrentSeedingRule { Name = "tv", Categories = ["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");
|
||||
result.ShouldNotBeNull();
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(x => x.Category == "movies");
|
||||
result.ShouldContain(x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -154,17 +153,17 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new UTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -178,17 +177,17 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "music" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
var categories = new List<ISeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
new UTorrentSeedingRule { Name = "movies", Categories = ["movies"], MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,12 +210,12 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -231,11 +230,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -251,12 +250,31 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new List<string> { "movies" });
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("hash1", result[0].Hash);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmpty_WhenNoCategoriesMatch()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "tv" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, new UnlinkedConfig { Categories = ["movies"] });
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,20 +309,19 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
|
||||
mockTorrent.Hash.Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
|
||||
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
await sut.DeleteDownload(mockTorrent, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -313,20 +330,19 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "UPPERCASE-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
|
||||
mockTorrent.Hash.Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
|
||||
.RemoveTorrentsAsync(Arg.Any<List<string>>(), true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
await sut.DeleteDownload(mockTorrent, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("uppercase-hash")), true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -335,20 +351,19 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
|
||||
mockTorrent.Hash.Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
|
||||
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), false)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(mockTorrent.Object, false);
|
||||
await sut.DeleteDownload(mockTorrent, false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,18 +379,17 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -384,18 +398,17 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>());
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<Domain.Entities.ITorrentItemWrapper>(), unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -404,12 +417,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -419,10 +431,10 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -431,12 +443,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -446,10 +457,10 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -458,12 +469,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -473,10 +483,10 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -485,12 +495,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -500,23 +509,22 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync("hash1")
|
||||
.Returns(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>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.SetTorrentLabelAsync("hash1", "unlinked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -525,12 +533,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -540,21 +547,21 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync("hash1")
|
||||
.Returns(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>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(2);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -563,12 +570,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -578,21 +584,21 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync("hash1")
|
||||
.Returns(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>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(-1);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -601,12 +607,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -616,24 +621,23 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync("hash1")
|
||||
.Returns(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>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
_fixture.HardLinkFileService.Received(1)
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -642,12 +646,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -657,23 +660,22 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync("hash1")
|
||||
.Returns(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>()))
|
||||
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - EventPublisher is not mocked, so we just verify the method completed
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetTorrentLabelAsync("hash1", "unlinked"),
|
||||
Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1)
|
||||
.SetTorrentLabelAsync("hash1", "unlinked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -682,12 +684,11 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
var unlinkedConfig = new UnlinkedConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
TargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
@@ -697,14 +698,14 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("hash1"))
|
||||
.ReturnsAsync((List<UTorrentFile>?)null);
|
||||
.GetTorrentFilesAsync("hash1")
|
||||
.Returns((List<UTorrentFile>?)null);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
|
||||
|
||||
// Assert - When files is null, it uses empty collection and proceeds to change label
|
||||
_fixture.ClientWrapper.Verify(x => x.SetTorrentLabelAsync("hash1", "unlinked"), Times.Once);
|
||||
await _fixture.ClientWrapper.Received(1).SetTorrentLabelAsync("hash1", "unlinked");
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-49
@@ -8,42 +8,48 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
using NSubstitute;
|
||||
|
||||
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 ILogger<UTorrentService> Logger { get; private set; }
|
||||
public IFilenameEvaluator FilenameEvaluator { get; private set; }
|
||||
public IStriker Striker { get; private set; }
|
||||
public IDryRunInterceptor DryRunInterceptor { get; private set; }
|
||||
public IHardLinkFileService HardLinkFileService { get; private set; }
|
||||
public IDynamicHttpClientProvider HttpClientProvider { get; private set; }
|
||||
public IEventPublisher EventPublisher { get; private set; }
|
||||
public IBlocklistProvider BlocklistProvider { get; private set; }
|
||||
public IQueueRuleEvaluator RuleEvaluator { get; private set; }
|
||||
public IQueueRuleManager RuleManager { get; private set; }
|
||||
public ISeedingRuleEvaluator SeedingRuleEvaluator { get; private set; }
|
||||
public IUTorrentClientWrapper ClientWrapper { get; private set; }
|
||||
|
||||
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>();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<UTorrentService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<IUTorrentClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
@@ -65,42 +71,47 @@ public class UTorrentServiceFixture : IDisposable
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.CreateClient(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(httpClient);
|
||||
|
||||
return new UTorrentService(
|
||||
Logger.Object,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider.Object,
|
||||
Logger,
|
||||
FilenameEvaluator,
|
||||
Striker,
|
||||
DryRunInterceptor,
|
||||
HardLinkFileService,
|
||||
HttpClientProvider,
|
||||
EventPublisher,
|
||||
BlocklistProvider,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
RuleEvaluator,
|
||||
SeedingRuleEvaluator,
|
||||
ClientWrapper
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
SubstituteHelper.ClearPendingArgSpecs();
|
||||
Logger = Substitute.For<ILogger<UTorrentService>>();
|
||||
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
Striker = Substitute.For<IStriker>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
RuleEvaluator = Substitute.For<IQueueRuleEvaluator>();
|
||||
RuleManager = Substitute.For<IQueueRuleManager>();
|
||||
SeedingRuleEvaluator = Substitute.For<ISeedingRuleEvaluator>();
|
||||
ClientWrapper = Substitute.For<IUTorrentClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
.InterceptAsync(default!, default!)
|
||||
.ReturnsForAnyArgs(callInfo =>
|
||||
{
|
||||
var action = callInfo.ArgAt<Delegate>(0);
|
||||
var parameters = callInfo.ArgAt<object[]>(1);
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
+124
-122
@@ -1,7 +1,9 @@
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
@@ -29,14 +31,14 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync((UTorrentItem?)null);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns((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);
|
||||
result.Found.ShouldBeFalse();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -61,32 +63,32 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(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));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -111,32 +113,32 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(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));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsPrivate.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,16 +170,16 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(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 }
|
||||
@@ -185,9 +187,9 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AllFilesSkipped);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -212,32 +214,32 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(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));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,17 +271,17 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { hash });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -306,17 +308,17 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { category });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -342,17 +344,17 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { trackerDomain });
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,29 +386,29 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ThrowsAsync(new InvalidOperationException("Failed to get files"));
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Throws(new InvalidOperationException("Failed to get files"));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<UTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,28 +440,28 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(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));
|
||||
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -484,28 +486,28 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(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));
|
||||
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
await _fixture.RuleEvaluator.DidNotReceive().EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,29 +539,29 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(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));
|
||||
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -585,29 +587,29 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(torrentItem);
|
||||
.GetTorrentAsync(hash)
|
||||
.Returns(torrentItem);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentPropertiesAsync(hash))
|
||||
.ReturnsAsync(torrentProperties);
|
||||
.GetTorrentPropertiesAsync(hash)
|
||||
.Returns(torrentProperties);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<UTorrentFile>
|
||||
.GetTorrentFilesAsync(hash)
|
||||
.Returns(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));
|
||||
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
|
||||
.Returns((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);
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
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
|
||||
}
|
||||
Loaded 100 of 369 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user