1fe7392063
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>
628 lines
20 KiB
Python
628 lines
20 KiB
Python
"""
|
|
Unit tests for FastAPI endpoints
|
|
"""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from unittest.mock import patch, AsyncMock, Mock
|
|
from datetime import datetime
|
|
|
|
# Import the FastAPI app
|
|
from main import app
|
|
|
|
|
|
class TestAPIRoot:
|
|
"""Tests for root endpoint"""
|
|
|
|
def test_root_endpoint(self):
|
|
"""Test root endpoint returns API info"""
|
|
client = TestClient(app)
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["message"] == "Ohm Stream Downloader API"
|
|
assert data["status"] == "running"
|
|
assert "version" in data
|
|
assert "endpoints" in data
|
|
|
|
def test_root_endpoint_version(self):
|
|
"""Test that API version is present"""
|
|
client = TestClient(app)
|
|
response = client.get("/")
|
|
data = response.json()
|
|
assert data["version"] in ["2.1", "2.2"]
|
|
|
|
|
|
class TestAPIHealth:
|
|
"""Tests for health check endpoint"""
|
|
|
|
def test_health_check(self):
|
|
"""Test health check endpoint"""
|
|
client = TestClient(app)
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "healthy"
|
|
|
|
|
|
class TestAPIProviders:
|
|
"""Tests for providers endpoint"""
|
|
|
|
def test_providers_list(self):
|
|
"""Test getting list of providers"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/providers")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "anime_providers" in data
|
|
assert "file_hosts" in data
|
|
assert isinstance(data["anime_providers"], dict)
|
|
assert isinstance(data["file_hosts"], dict)
|
|
# Check for known providers
|
|
assert "anime-sama" in data["anime_providers"]
|
|
assert "neko-sama" in data["anime_providers"]
|
|
|
|
|
|
class TestAPIDownloadCreate:
|
|
"""Tests for download creation endpoint"""
|
|
|
|
def test_create_download_success(self):
|
|
"""Test creating a new download"""
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/api/download",
|
|
json={"url": "https://example.com/file.mp4"}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "task_id" in data
|
|
# Status is in the task object
|
|
assert "task" in data
|
|
assert data["task"]["status"] == "pending"
|
|
|
|
def test_create_download_with_filename(self):
|
|
"""Test creating download with custom filename"""
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/api/download",
|
|
json={
|
|
"url": "https://example.com/file.mp4",
|
|
"filename": "custom_name.mp4"
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "task_id" in data
|
|
|
|
def test_create_download_invalid_url(self):
|
|
"""Test creating download with invalid URL"""
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/api/download",
|
|
json={"url": "not-a-valid-url"}
|
|
)
|
|
# API accepts the URL even if invalid (will fail later)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "task_id" in data
|
|
|
|
def test_create_download_missing_url(self):
|
|
"""Test creating download without URL"""
|
|
client = TestClient(app)
|
|
response = client.post("/api/download", json={})
|
|
assert response.status_code == 422
|
|
|
|
|
|
class TestAPIDownloadList:
|
|
"""Tests for download list endpoint"""
|
|
|
|
def test_list_downloads_empty(self):
|
|
"""Test listing downloads when none exist"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/downloads")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "downloads" in data
|
|
assert isinstance(data["downloads"], list)
|
|
# May or may not be empty depending on test order
|
|
|
|
def test_list_downloads_after_creation(self):
|
|
"""Test listing downloads after creating one"""
|
|
client = TestClient(app)
|
|
# Create a download first
|
|
create_response = client.post(
|
|
"/api/download",
|
|
json={"url": "https://example.com/test.mp4"}
|
|
)
|
|
assert create_response.status_code == 200
|
|
|
|
# List downloads
|
|
response = client.get("/api/downloads")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["downloads"]) >= 1
|
|
|
|
|
|
class TestAPIDownloadDetails:
|
|
"""Tests for download details endpoint"""
|
|
|
|
def test_get_download_details_success(self):
|
|
"""Test getting details of existing download"""
|
|
client = TestClient(app)
|
|
# Create a download first
|
|
create_response = client.post(
|
|
"/api/download",
|
|
json={"url": "https://example.com/details.mp4"}
|
|
)
|
|
task_id = create_response.json()["task_id"]
|
|
|
|
# Get details
|
|
response = client.get(f"/api/download/{task_id}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == task_id
|
|
assert "url" in data
|
|
assert "status" in data
|
|
|
|
def test_get_download_details_not_found(self):
|
|
"""Test getting details of non-existent download"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/download/nonexistent-id")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestAPIDownloadPause:
|
|
"""Tests for pause download endpoint"""
|
|
|
|
def test_pause_download_success(self):
|
|
"""Test pausing a download"""
|
|
client = TestClient(app)
|
|
# Create a download first
|
|
create_response = client.post(
|
|
"/api/download",
|
|
json={"url": "https://example.com/pause.mp4"}
|
|
)
|
|
task_id = create_response.json()["task_id"]
|
|
|
|
# Pause it
|
|
response = client.post(f"/api/download/{task_id}/pause")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "paused"
|
|
|
|
def test_pause_download_not_found(self):
|
|
"""Test pausing non-existent download"""
|
|
client = TestClient(app)
|
|
response = client.post("/api/download/nonexistent-id/pause")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestAPIDownloadResume:
|
|
"""Tests for resume download endpoint"""
|
|
|
|
def test_resume_download_success(self):
|
|
"""Test resuming a paused download"""
|
|
client = TestClient(app)
|
|
# Create and pause a download
|
|
create_response = client.post(
|
|
"/api/download",
|
|
json={"url": "https://example.com/resume.mp4"}
|
|
)
|
|
task_id = create_response.json()["task_id"]
|
|
|
|
# Pause first
|
|
client.post(f"/api/download/{task_id}/pause")
|
|
|
|
# Resume
|
|
response = client.post(f"/api/download/{task_id}/resume")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "status" in data
|
|
assert data["status"] in ["resumed", "already running or completed"]
|
|
|
|
|
|
class TestAPIDownloadCancel:
|
|
"""Tests for cancel download endpoint"""
|
|
|
|
def test_cancel_download_success(self):
|
|
"""Test canceling a download"""
|
|
client = TestClient(app)
|
|
# Create a download first
|
|
create_response = client.post(
|
|
"/api/download",
|
|
json={"url": "https://example.com/cancel.mp4"}
|
|
)
|
|
task_id = create_response.json()["task_id"]
|
|
|
|
# Cancel it (DELETE marks as deleted)
|
|
response = client.delete(f"/api/download/{task_id}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "deleted"
|
|
|
|
def test_cancel_download_not_found(self):
|
|
"""Test canceling non-existent download"""
|
|
client = TestClient(app)
|
|
response = client.delete("/api/download/nonexistent-id")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestAPIAnimeSearch:
|
|
"""Tests for anime search endpoint"""
|
|
|
|
def test_anime_search_missing_query(self):
|
|
"""Test anime search without query parameter"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/anime/search")
|
|
# Now returns 422 for validation error
|
|
assert response.status_code == 422 # Bad request
|
|
|
|
def test_anime_search_with_query(self):
|
|
"""Test anime search with query parameter"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/anime/search?q=naruto")
|
|
# This will likely fail without actual network/anime sites
|
|
# but we're testing the endpoint exists
|
|
assert response.status_code in [200, 500, 503] # Various possibilities
|
|
|
|
def test_anime_search_with_language(self):
|
|
"""Test anime search with language parameter"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/anime/search?q=one+piece&lang=vostfr")
|
|
# Similar to above, testing endpoint exists
|
|
assert response.status_code in [200, 500, 503]
|
|
|
|
def test_anime_search_with_metadata(self):
|
|
"""Test anime search with metadata flag"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/anime/search?q=bleach&include_metadata=true")
|
|
# Testing endpoint accepts the parameter
|
|
assert response.status_code in [200, 500, 503]
|
|
|
|
|
|
class TestAPIAnimeMetadata:
|
|
"""Tests for anime metadata endpoint"""
|
|
|
|
def test_anime_metadata_missing_url(self):
|
|
"""Test metadata endpoint without URL"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/anime/metadata")
|
|
# Returns 422 for validation error
|
|
assert response.status_code == 422
|
|
|
|
def test_anime_metadata_with_url(self):
|
|
"""Test metadata endpoint with URL"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/anime/metadata?url=https://anime-sama.si/test/")
|
|
# Will likely fail without real anime site
|
|
assert response.status_code in [200, 404, 500, 503]
|
|
|
|
|
|
class TestAPIAnimeEpisodes:
|
|
"""Tests for anime episodes endpoint"""
|
|
|
|
def test_anime_episodes_missing_url(self):
|
|
"""Test episodes endpoint without URL"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/anime/episodes")
|
|
# Returns 422 for validation error
|
|
assert response.status_code == 422
|
|
|
|
def test_anime_episodes_with_url(self):
|
|
"""Test episodes endpoint with URL"""
|
|
client = TestClient(app)
|
|
response = client.get(
|
|
"/api/anime/episodes?url=https://anime-sama.si/test/&lang=vostfr"
|
|
)
|
|
# Will likely fail without real anime site
|
|
assert response.status_code in [200, 404, 500, 503]
|
|
|
|
|
|
class TestAPIFavorites:
|
|
"""Tests for favorites endpoints"""
|
|
|
|
def test_list_favorites_empty(self):
|
|
"""Test listing favorites when empty"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/favorites")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "favorites" in data
|
|
assert "total" in data
|
|
|
|
def test_add_favorite_success(self):
|
|
"""Test adding a favorite"""
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/api/favorites",
|
|
json={
|
|
"anime_id": "test-anime-123",
|
|
"title": "Test Anime",
|
|
"url": "https://example.com/anime",
|
|
"provider": "anime-sama",
|
|
"metadata": {"genres": ["Action"]},
|
|
"poster_url": "https://example.com/poster.jpg"
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "added"
|
|
assert "favorite" in data
|
|
|
|
def test_add_favorite_missing_fields(self):
|
|
"""Test adding favorite with missing required fields"""
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/api/favorites",
|
|
json={"title": "Test Anime"} # Missing anime_id, url, provider
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_remove_favorite_success(self):
|
|
"""Test removing a favorite"""
|
|
client = TestClient(app)
|
|
# Add first
|
|
client.post(
|
|
"/api/favorites",
|
|
json={
|
|
"anime_id": "test-remove-123",
|
|
"title": "Remove Me",
|
|
"url": "https://example.com",
|
|
"provider": "anime-sama"
|
|
}
|
|
)
|
|
|
|
# Remove
|
|
response = client.delete("/api/favorites/test-remove-123")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "removed"
|
|
|
|
def test_remove_favorite_not_found(self):
|
|
"""Test removing non-existent favorite"""
|
|
client = TestClient(app)
|
|
response = client.delete("/api/favorites/nonexistent-id")
|
|
assert response.status_code == 404
|
|
|
|
def test_get_favorite_success(self):
|
|
"""Test getting a specific favorite"""
|
|
client = TestClient(app)
|
|
# Add first
|
|
client.post(
|
|
"/api/favorites",
|
|
json={
|
|
"anime_id": "test-get-123",
|
|
"title": "Get Me",
|
|
"url": "https://example.com",
|
|
"provider": "anime-sama"
|
|
}
|
|
)
|
|
|
|
# Get
|
|
response = client.get("/api/favorites/test-get-123")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "favorite" in data
|
|
assert data["favorite"]["id"] == "test-get-123"
|
|
|
|
def test_get_favorite_not_found(self):
|
|
"""Test getting non-existent favorite"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/favorites/nonexistent-id")
|
|
assert response.status_code == 404
|
|
|
|
def test_get_favorites_stats(self):
|
|
"""Test getting favorites statistics"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/favorites/stats")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "total" in data
|
|
assert "by_provider" in data
|
|
assert "by_genre" in data
|
|
|
|
def test_toggle_favorite_add(self):
|
|
"""Test toggling favorite to add"""
|
|
client = TestClient(app)
|
|
# Make sure it doesn't exist first
|
|
try:
|
|
client.delete("/api/favorites/test-toggle-add")
|
|
except:
|
|
pass
|
|
|
|
response = client.post(
|
|
"/api/favorites/toggle",
|
|
json={
|
|
"anime_id": "test-toggle-add",
|
|
"title": "Toggle Add",
|
|
"url": "https://example.com",
|
|
"provider": "anime-sama"
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["action"] == "added"
|
|
|
|
def test_toggle_favorite_remove(self):
|
|
"""Test toggling favorite to remove"""
|
|
client = TestClient(app)
|
|
# Make sure it doesn't exist first
|
|
try:
|
|
client.delete("/api/favorites/test-toggle-remove")
|
|
except:
|
|
pass
|
|
|
|
# Add first
|
|
client.post(
|
|
"/api/favorites/toggle",
|
|
json={
|
|
"anime_id": "test-toggle-remove",
|
|
"title": "Toggle Remove",
|
|
"url": "https://example.com",
|
|
"provider": "anime-sama"
|
|
}
|
|
)
|
|
|
|
# Toggle to remove
|
|
response = client.post(
|
|
"/api/favorites/toggle",
|
|
json={
|
|
"anime_id": "test-toggle-remove",
|
|
"title": "Toggle Remove",
|
|
"url": "https://example.com",
|
|
"provider": "anime-sama"
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["action"] == "removed"
|
|
|
|
|
|
class TestAPIFavoritesSorting:
|
|
"""Tests for favorites sorting and filtering"""
|
|
|
|
def test_favorites_sort_by_title(self):
|
|
"""Test sorting favorites by title"""
|
|
client = TestClient(app)
|
|
# Add multiple favorites
|
|
for title in ["Z Anime", "A Anime", "M Anime"]:
|
|
client.post(
|
|
"/api/favorites",
|
|
json={
|
|
"anime_id": f"sort-{title}",
|
|
"title": title,
|
|
"url": f"https://example.com/{title}",
|
|
"provider": "anime-sama"
|
|
}
|
|
)
|
|
|
|
response = client.get("/api/favorites?sort_by=title&order=asc")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
if len(data["favorites"]) > 0:
|
|
# Check if sorted
|
|
titles = [f["title"] for f in data["favorites"][:3]]
|
|
assert titles == sorted(titles)
|
|
|
|
def test_favorites_filter_by_provider(self):
|
|
"""Test filtering favorites by provider"""
|
|
client = TestClient(app)
|
|
# Add favorites with different providers
|
|
client.post(
|
|
"/api/favorites",
|
|
json={
|
|
"anime_id": "filter-anime-sama",
|
|
"title": "Anime Sama",
|
|
"url": "https://example.com",
|
|
"provider": "anime-sama"
|
|
}
|
|
)
|
|
client.post(
|
|
"/api/favorites",
|
|
json={
|
|
"anime_id": "filter-neko-sama",
|
|
"title": "Neko Sama",
|
|
"url": "https://example.com",
|
|
"provider": "neko-sama"
|
|
}
|
|
)
|
|
|
|
response = client.get("/api/favorites?filter_provider=anime-sama")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# Should only have anime-sama
|
|
for fav in data["favorites"]:
|
|
assert fav["provider"] == "anime-sama"
|
|
|
|
|
|
class TestAPIWebInterface:
|
|
"""Tests for web interface endpoint"""
|
|
|
|
def test_web_interface(self):
|
|
"""Test web interface endpoint"""
|
|
client = TestClient(app)
|
|
response = client.get("/web")
|
|
assert response.status_code == 200
|
|
# Should return HTML
|
|
assert "text/html" in response.headers["content-type"]
|
|
|
|
|
|
class TestAPIVideoStreaming:
|
|
"""Tests for video streaming endpoints"""
|
|
|
|
def test_video_player_by_id_not_found(self):
|
|
"""Test video player with non-existent task ID"""
|
|
client = TestClient(app)
|
|
response = client.get("/player/nonexistent-id")
|
|
# Should return 404 or similar
|
|
assert response.status_code in [404, 403]
|
|
|
|
def test_video_stream_by_filename_not_found(self):
|
|
"""Test video stream with non-existent filename"""
|
|
client = TestClient(app)
|
|
response = client.get("/stream/nonexistent_file.mp4")
|
|
# Should return 404
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestAPIErrorHandling:
|
|
"""Tests for API error handling"""
|
|
|
|
def test_invalid_endpoint(self):
|
|
"""Test accessing invalid endpoint"""
|
|
client = TestClient(app)
|
|
response = client.get("/api/invalid_endpoint")
|
|
assert response.status_code == 404
|
|
|
|
def test_invalid_method(self):
|
|
"""Test using invalid HTTP method"""
|
|
client = TestClient(app)
|
|
response = client.post("/api/downloads") # Should be GET
|
|
assert response.status_code == 405 # Method Not Allowed
|
|
|
|
def test_malformed_json(self):
|
|
"""Test sending malformed JSON"""
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/api/download",
|
|
data="invalid json",
|
|
headers={"Content-Type": "application/json"}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
class TestAPICorsHeaders:
|
|
"""Tests for CORS headers"""
|
|
|
|
def test_cors_headers_present(self):
|
|
"""Test that CORS headers are present"""
|
|
client = TestClient(app)
|
|
response = client.get("/")
|
|
# Check for CORS headers
|
|
# Note: Actual CORS configuration may vary
|
|
# This is just to ensure the endpoint responds
|
|
assert response.status_code == 200
|
|
|
|
|
|
# Mock-based tests for endpoints requiring external resources
|
|
class TestAPIWithMocks:
|
|
"""Tests with mocked external dependencies"""
|
|
|
|
def test_download_with_mocked_downloader(self):
|
|
"""Test download creation with mocked downloader"""
|
|
client = TestClient(app)
|
|
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",
|
|
"video.mp4"
|
|
)
|
|
mock_downloader.can_handle.return_value = True
|
|
mock_get_downloader.return_value = mock_downloader
|
|
|
|
response = client.post(
|
|
"/api/download",
|
|
json={"url": "https://doodstream.com/test"}
|
|
)
|
|
# Should succeed
|
|
assert response.status_code == 200
|