Files
ohm_streaming/tests/test_api.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

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