Files
ohm_streaming/tests/test_download_manager.py
T
root 1fe7392063 feat: Complete Sonarr integration with security enhancements
This commit adds comprehensive Sonarr webhook integration and implements
critical security improvements identified in code review.

## Sonarr Integration
- Full webhook support for Grab, Download, Rename, Delete, and Test events
- HMAC SHA256 signature verification for webhook authentication
- Series mapping system (Sonarr TVDB ID → Anime Provider URL)
- 11 new API endpoints for configuration, mappings, search, and downloads
- Comprehensive test suite (31 tests, all passing)
- Complete documentation in docs/SONARR_INTEGRATION.md

## Security Enhancements
- CORS restricted to specific origins (user's IP: 192.168.1.204:3000)
- Path traversal prevention via sanitize_filename() and is_safe_filename()
- Structured logging infrastructure (replaced all print() statements)
- Environment-based configuration with .env support
- Filename sanitization prevents malicious path attacks

## New Features
- Lpayer and Sibnet downloader support
- Kitsu API integration for anime metadata
- Recommendation engine based on download history
- Latest releases endpoint for new anime
- Modular web interface with component-based templates

## Configuration
- Centralized settings via app/config.py with pydantic-settings
- Sonarr config auto-created in config/ directory
- Example configurations provided for easy setup

## Tests
- 31 Sonarr integration tests (23 functionality + 9 security)
- 100+ tests passing in core test files
- Security utilities fully tested

## Documentation
- Updated CLAUDE.md with Sonarr and testing info
- Added IMPROVEMENTS_2024-01-24.md analysis
- Added SONARR_IMPLEMENTATION.md technical summary

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-24 21:25:47 +00:00

396 lines
15 KiB
Python

"""
Unit tests for DownloadManager
"""
import pytest
import asyncio
from pathlib import Path
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
from app.download_manager import DownloadManager
from app.models import DownloadTask, DownloadStatus, DownloadRequest, HostType
class TestDownloadManagerInit:
"""Tests for DownloadManager initialization"""
def test_init_default_parameters(self, temp_download_dir):
"""Test DownloadManager initialization with default parameters"""
manager = DownloadManager(download_dir=str(temp_download_dir))
assert manager.download_dir == temp_download_dir
assert manager.max_parallel == 3
assert manager.tasks == {}
assert manager.active_downloads == {}
assert temp_download_dir.exists()
def test_init_custom_parameters(self, temp_download_dir):
"""Test DownloadManager initialization with custom parameters"""
manager = DownloadManager(
download_dir=str(temp_download_dir),
max_parallel=5
)
assert manager.max_parallel == 5
def test_init_creates_download_directory(self, temp_dir):
"""Test that initialization creates download directory"""
download_dir = temp_dir / "new_downloads"
assert not download_dir.exists()
manager = DownloadManager(download_dir=str(download_dir))
assert download_dir.exists()
assert download_dir.is_dir()
class TestDownloadManagerTaskCreation:
"""Tests for task creation and management"""
def test_create_task(self, download_manager, sample_download_request):
"""Test creating a new download task"""
task = download_manager.create_task(sample_download_request)
assert isinstance(task, DownloadTask)
assert task.url == sample_download_request.url
assert task.filename == sample_download_request.filename
assert task.status == DownloadStatus.PENDING
assert task.id in download_manager.tasks
def test_create_task_with_custom_filename(self, download_manager):
"""Test creating task with custom filename"""
request = DownloadRequest(
url="https://example.com/file.mp4",
filename="custom_name.mp4"
)
task = download_manager.create_task(request)
assert task.filename == "custom_name.mp4"
def test_create_task_without_filename(self, download_manager):
"""Test creating task without filename uses default"""
request = DownloadRequest(url="https://example.com/file.mp4")
task = download_manager.create_task(request)
assert task.filename == "download"
def test_create_multiple_tasks(self, download_manager):
"""Test creating multiple tasks"""
request1 = DownloadRequest(url="https://example.com/file1.mp4")
request2 = DownloadRequest(url="https://example.com/file2.mp4")
task1 = download_manager.create_task(request1)
task2 = download_manager.create_task(request2)
assert task1.id != task2.id
assert len(download_manager.tasks) == 2
def test_get_task(self, download_manager, sample_download_request):
"""Test retrieving a task by ID"""
task = download_manager.create_task(sample_download_request)
retrieved_task = download_manager.get_task(task.id)
assert retrieved_task is not None
assert retrieved_task.id == task.id
assert retrieved_task.url == task.url
def test_get_task_nonexistent(self, download_manager):
"""Test retrieving a non-existent task"""
task = download_manager.get_task("nonexistent-id")
assert task is None
def test_get_all_tasks(self, download_manager):
"""Test retrieving all tasks"""
# Create multiple tasks
for i in range(3):
request = DownloadRequest(url=f"https://example.com/file{i}.mp4")
download_manager.create_task(request)
all_tasks = download_manager.get_all_tasks()
assert len(all_tasks) == 3
assert all(isinstance(task, DownloadTask) for task in all_tasks)
def test_get_all_tasks_empty(self, download_manager):
"""Test retrieving all tasks when none exist"""
all_tasks = download_manager.get_all_tasks()
assert all_tasks == []
class TestDownloadManagerStartDownload:
"""Tests for starting downloads"""
@pytest.mark.asyncio
async def test_start_download_success(self, download_manager, sample_download_request):
"""Test starting a download successfully"""
task = download_manager.create_task(sample_download_request)
# Mock the actual download process
with patch('app.download_manager.get_downloader') as mock_get_downloader:
mock_downloader = AsyncMock()
mock_downloader.get_download_link.return_value = (
"https://example.com/direct_link",
"video.mp4"
)
mock_get_downloader.return_value = mock_downloader
# Mock httpx client
with patch('httpx.AsyncClient') as mock_client_class:
mock_client = AsyncMock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-length": "1000"}
mock_response.raise_for_status = Mock()
# Mock streaming
async def mock_aiter_bytes(chunk_size):
yield b"x" * 1000 # Single chunk
mock_response.aiter_bytes = mock_aiter_bytes
mock_client.stream.return_value.__aenter__.return_value = mock_response
mock_client_class.return_value = mock_client
await download_manager.start_download(task.id)
# Give it a moment to start
await asyncio.sleep(0.1)
@pytest.mark.asyncio
async def test_start_download_nonexistent_task(self, download_manager):
"""Test starting download for non-existent task"""
with pytest.raises(ValueError, match="not found"):
await download_manager.start_download("nonexistent-id")
@pytest.mark.asyncio
async def test_start_download_already_downloading(self, download_manager, sample_download_request):
"""Test starting a task that's already downloading"""
task = download_manager.create_task(sample_download_request)
task.status = DownloadStatus.DOWNLOADING
# Should not raise an error, just return
await download_manager.start_download(task.id)
class TestDownloadManagerPauseDownload:
"""Tests for pausing downloads"""
@pytest.mark.asyncio
async def test_pause_download(self, download_manager):
"""Test pausing an active download"""
request = DownloadRequest(url="https://example.com/file.mp4")
task = download_manager.create_task(request)
task.status = DownloadStatus.DOWNLOADING
# Create a fake active download
async def fake_download():
await asyncio.sleep(10)
download_manager.active_downloads[task.id] = asyncio.create_task(fake_download())
await download_manager.pause_download(task.id)
assert task.status == DownloadStatus.PAUSED
assert task.id not in download_manager.active_downloads
@pytest.mark.asyncio
async def test_pause_download_not_downloading(self, download_manager):
"""Test pausing a task that's not downloading"""
request = DownloadRequest(url="https://example.com/file.mp4")
task = download_manager.create_task(request)
task.status = DownloadStatus.PENDING
# Should not crash
await download_manager.pause_download(task.id)
assert task.status == DownloadStatus.PENDING
@pytest.mark.asyncio
async def test_pause_nonexistent_task(self, download_manager):
"""Test pausing a non-existent task"""
# Should not crash
await download_manager.pause_download("nonexistent-id")
class TestDownloadManagerCancelDownload:
"""Tests for canceling downloads"""
@pytest.mark.asyncio
async def test_cancel_download(self, download_manager, temp_download_dir):
"""Test canceling a download"""
request = DownloadRequest(url="https://example.com/file.mp4")
task = download_manager.create_task(request)
task.status = DownloadStatus.DOWNLOADING
# Create a fake file
file_path = temp_download_dir / "test_file.mp4"
file_path.write_text("test content")
task.file_path = str(file_path)
await download_manager.cancel_download(task.id)
assert task.status == DownloadStatus.CANCELLED
assert not file_path.exists()
@pytest.mark.asyncio
async def test_cancel_download_with_active_task(self, download_manager):
"""Test canceling a download with an active task"""
request = DownloadRequest(url="https://example.com/file.mp4")
task = download_manager.create_task(request)
task.status = DownloadStatus.DOWNLOADING
# Create a fake active download
async def fake_download():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
raise
fake_task = asyncio.create_task(fake_download())
download_manager.active_downloads[task.id] = fake_task
await download_manager.cancel_download(task.id)
assert task.status == DownloadStatus.CANCELLED
assert task.id not in download_manager.active_downloads
@pytest.mark.asyncio
async def test_cancel_nonexistent_task(self, download_manager):
"""Test canceling a non-existent task"""
# Should not crash
await download_manager.cancel_download("nonexistent-id")
class TestDownloadManagerConcurrency:
"""Tests for concurrent download management"""
def test_semaphore_limit(self, download_manager):
"""Test that semaphore respects max_parallel limit"""
manager = DownloadManager(max_parallel=2)
assert manager._semaphore._value == 2
@pytest.mark.asyncio
async def test_parallel_downloads_respect_limit(self, download_manager):
"""Test that parallel downloads respect the limit"""
download_count = 0
max_concurrent = 0
async def mock_download():
nonlocal download_count, max_concurrent
download_count += 1
max_concurrent = max(max_concurrent, download_count)
await asyncio.sleep(0.1)
download_count -= 1
# Start more downloads than max_parallel
tasks = []
for _ in range(5):
task = asyncio.create_task(mock_download())
tasks.append(task)
await asyncio.gather(*tasks)
# With proper semaphore, should not exceed max_parallel
# Note: This is a basic test, real concurrency testing needs more setup
class TestDownloadManagerProgressTracking:
"""Tests for progress tracking during downloads"""
def test_task_progress_initialization(self, download_manager):
"""Test that task initializes with correct progress values"""
request = DownloadRequest(url="https://example.com/file.mp4")
task = download_manager.create_task(request)
assert task.progress == 0.0
assert task.downloaded_bytes == 0
assert task.speed == 0.0
assert task.total_bytes is None
def test_task_timestamps(self, download_manager):
"""Test that task timestamps are set correctly"""
request = DownloadRequest(url="https://example.com/file.mp4")
task = download_manager.create_task(request)
assert task.created_at is not None
assert isinstance(task.created_at, datetime)
assert task.started_at is None
assert task.completed_at is None
class TestDownloadManagerFileHandling:
"""Tests for file handling during downloads"""
def test_file_path_assignment(self, download_manager, temp_download_dir):
"""Test that file paths are assigned correctly"""
request = DownloadRequest(
url="https://example.com/video.mp4",
filename="test_video.mp4"
)
task = download_manager.create_task(request)
expected_path = str(temp_download_dir / "test_video.mp4")
# File path is set during download, not creation
assert task.file_path is None or task.file_path == expected_path
def test_download_dir_persistence(self, download_manager):
"""Test that download directory persists across operations"""
assert download_manager.download_dir.exists()
assert download_manager.download_dir.is_dir()
class TestDownloadManagerErrorHandling:
"""Tests for error handling"""
@pytest.mark.asyncio
async def test_download_error_sets_failed_status(self):
"""Test that download errors set task to failed status"""
temp_dir = Path(__file__).parent / "temp_test_downloads"
temp_dir.mkdir(exist_ok=True)
try:
manager = DownloadManager(download_dir=str(temp_dir))
request = DownloadRequest(url="https://invalid-url-that-will-fail.com/file.mp4")
task = manager.create_task(request)
# Mock get_downloader to raise an error
with patch('app.download_manager.get_downloader') as mock_get_downloader:
mock_downloader = AsyncMock()
mock_downloader.get_download_link.side_effect = Exception("Download failed")
mock_get_downloader.return_value = mock_downloader
try:
await manager.start_download(task.id)
await asyncio.sleep(0.1) # Give it time to process
except:
pass
# The task should be in tasks dict
if task.id in manager.tasks:
assert manager.tasks[task.id].status == DownloadStatus.FAILED
assert manager.tasks[task.id].error is not None
finally:
# Cleanup
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
class TestDownloadManagerEdgeCases:
"""Tests for edge cases and boundary conditions"""
def test_create_task_with_special_chars_in_filename(self, download_manager):
"""Test creating task with special characters in filename"""
request = DownloadRequest(
url="https://example.com/file.mp4",
filename="test file [2023] - épisode 1.mp4"
)
task = download_manager.create_task(request)
assert "[" in task.filename
assert "]" in task.filename
def test_concurrent_task_creation(self, download_manager):
"""Test creating tasks concurrently"""
async def create_tasks():
tasks = []
for i in range(10):
request = DownloadRequest(url=f"https://example.com/file{i}.mp4")
task = download_manager.create_task(request)
tasks.append(task)
return tasks
tasks = asyncio.run(create_tasks())
assert len(tasks) == 10
assert len(download_manager.tasks) == 10