feat: Complete watchlist & auto-download system with UI
Implement comprehensive watchlist system with automatic episode detection
and downloading. Features include per-user watchlists, scheduler-based
periodic checks, and a modern web UI.
**Backend Components:**
- WatchlistManager: JSON-based storage with multi-tenant support
- EpisodeChecker: Detects and downloads new episodes automatically
- AutoDownloadScheduler: APScheduler-based periodic task execution
- Complete REST API for CRUD operations and scheduler control
**Frontend Components:**
- Modern watchlist page with dark theme and animations
- Real-time status updates and progress tracking
- Scheduler controls with next-run display
- Add anime directly from search results
**Models & Configuration:**
- WatchlistItem with status, quality, and auto-download settings
- WatchlistSettings for global configuration
- Per-user statistics and provider tracking
**API Endpoints:**
- GET/POST /api/watchlist - List and add items
- PUT/DELETE /api/watchlist/{id} - Update and delete
- POST /api/watchlist/{id}/check - Manual check trigger
- POST /api/watchlist/check-all - Check all due items
- GET/PUT /api/watchlist/settings - Global settings
- GET /api/watchlist/stats - Statistics
- GET/POST /api/watchlist/scheduler/* - Scheduler control
**Configuration Files:**
- config/watchlist.json - User watchlist data
- config/watchlist_settings.json - Global settings
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,8 @@ async def favorites_manager(temp_dir):
|
||||
"""Create a FavoritesManager instance with temporary storage"""
|
||||
storage_path = temp_dir / "test_favorites.json"
|
||||
manager = FavoritesManager(storage_path=str(storage_path))
|
||||
# Initialize asynchronously
|
||||
await manager._load()
|
||||
yield manager
|
||||
# Cleanup
|
||||
if storage_path.exists():
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Unit tests for authentication system (app/auth.py)
|
||||
Tests JWT tokens, user management, and password hashing
|
||||
"""
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch, Mock
|
||||
from app.auth import UserManager, create_access_token, verify_token, get_user_from_token
|
||||
|
||||
|
||||
class TestUserManager:
|
||||
"""Tests for UserManager class"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_users_file(self, temp_dir):
|
||||
"""Create a temporary users.json file"""
|
||||
return temp_dir / "users.json"
|
||||
|
||||
@pytest.fixture
|
||||
def user_manager(self, temp_users_file):
|
||||
"""Create a UserManager instance with temporary storage"""
|
||||
manager = UserManager(json_path=str(temp_users_file))
|
||||
yield manager
|
||||
# Cleanup
|
||||
if temp_users_file.exists():
|
||||
temp_users_file.unlink()
|
||||
|
||||
def test_user_manager_init_creates_file(self, user_manager, temp_users_file):
|
||||
"""Test that UserManager creates the users file on init"""
|
||||
assert temp_users_file.exists()
|
||||
data = json.loads(temp_users_file.read_text())
|
||||
assert "users" in data
|
||||
assert isinstance(data["users"], dict)
|
||||
|
||||
def test_user_manager_init_existing_file(self, temp_users_file):
|
||||
"""Test UserManager initialization with existing file"""
|
||||
# Create a file with existing data
|
||||
existing_data = {
|
||||
"users": {
|
||||
"existing_user": {
|
||||
"username": "existing_user",
|
||||
"password_hash": "hash",
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"last_login": None
|
||||
}
|
||||
}
|
||||
}
|
||||
temp_users_file.write_text(json.dumps(existing_data))
|
||||
|
||||
manager = UserManager(json_path=str(temp_users_file))
|
||||
# Should load existing data
|
||||
assert "existing_user" in manager.users
|
||||
|
||||
def test_create_user_success(self, user_manager):
|
||||
"""Test successful user creation"""
|
||||
user = user_manager.create_user("testuser", "password123")
|
||||
assert user["username"] == "testuser"
|
||||
assert "password_hash" in user
|
||||
assert "created_at" in user
|
||||
assert user["last_login"] is None
|
||||
assert "testuser" in user_manager.users
|
||||
|
||||
def test_create_user_hashing(self, user_manager):
|
||||
"""Test that passwords are properly hashed with bcrypt"""
|
||||
user = user_manager.create_user("testuser", "password123")
|
||||
# Hash should not be the plain password
|
||||
assert user["password_hash"] != "password123"
|
||||
# Bcrypt hashes start with $2b$
|
||||
assert user["password_hash"].startswith("$2b$")
|
||||
# Hash should be 60 characters (bcrypt standard)
|
||||
assert len(user["password_hash"]) == 60
|
||||
|
||||
def test_create_user_duplicate(self, user_manager):
|
||||
"""Test that duplicate usernames are rejected"""
|
||||
user_manager.create_user("testuser", "password123")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
user_manager.create_user("testuser", "different456")
|
||||
|
||||
def test_create_user_short_password(self, user_manager):
|
||||
"""Test that short passwords are rejected"""
|
||||
with pytest.raises(ValueError, match="at least 6 characters"):
|
||||
user_manager.create_user("testuser", "short")
|
||||
|
||||
def test_create_user_password_truncation(self, user_manager):
|
||||
"""Test that passwords longer than 72 bytes are truncated"""
|
||||
# Bcrypt has a 72-byte limit
|
||||
long_password = "a" * 100
|
||||
user = user_manager.create_user("testuser", long_password)
|
||||
# Should succeed (password truncated internally)
|
||||
assert user["username"] == "testuser"
|
||||
|
||||
def test_authenticate_user_success(self, user_manager):
|
||||
"""Test successful user authentication"""
|
||||
user_manager.create_user("testuser", "password123")
|
||||
user = user_manager.authenticate_user("testuser", "password123")
|
||||
assert user is not None
|
||||
assert user["username"] == "testuser"
|
||||
assert user["last_login"] is not None
|
||||
|
||||
def test_authenticate_user_wrong_password(self, user_manager):
|
||||
"""Test authentication with wrong password"""
|
||||
user_manager.create_user("testuser", "password123")
|
||||
user = user_manager.authenticate_user("testuser", "wrongpassword")
|
||||
assert user is None
|
||||
|
||||
def test_authenticate_user_nonexistent(self, user_manager):
|
||||
"""Test authentication with non-existent user"""
|
||||
user = user_manager.authenticate_user("nonexistent", "password")
|
||||
assert user is None
|
||||
|
||||
def test_authenticate_updates_last_login(self, user_manager):
|
||||
"""Test that authentication updates last_login timestamp"""
|
||||
user_manager.create_user("testuser", "password123")
|
||||
user_before = user_manager.users["testuser"]
|
||||
assert user_before["last_login"] is None
|
||||
|
||||
user_manager.authenticate_user("testuser", "password123")
|
||||
user_after = user_manager.users["testuser"]
|
||||
assert user_after["last_login"] is not None
|
||||
|
||||
def test_get_user(self, user_manager):
|
||||
"""Test getting a user by username"""
|
||||
user_manager.create_user("testuser", "password123")
|
||||
user = user_manager.get_user("testuser")
|
||||
assert user is not None
|
||||
assert user["username"] == "testuser"
|
||||
|
||||
def test_get_user_nonexistent(self, user_manager):
|
||||
"""Test getting a non-existent user"""
|
||||
user = user_manager.get_user("nonexistent")
|
||||
assert user is None
|
||||
|
||||
def test_update_user_last_login(self, user_manager):
|
||||
"""Test updating user's last login timestamp"""
|
||||
user_manager.create_user("testuser", "password123")
|
||||
user_manager.update_last_login("testuser")
|
||||
user = user_manager.users["testuser"]
|
||||
assert user["last_login"] is not None
|
||||
|
||||
def test_deprecated_scheme_migration(self, user_manager):
|
||||
"""Test migration from deprecated password schemes"""
|
||||
# This tests the passlib auto-migration feature
|
||||
# In practice, this is handled by passlib automatically
|
||||
user_manager.create_user("testuser", "password123")
|
||||
user = user_manager.users["testuser"]
|
||||
# Should use bcrypt scheme
|
||||
assert user["password_hash"].startswith("$2b$")
|
||||
|
||||
|
||||
class TestJWTTokens:
|
||||
"""Tests for JWT token creation and verification"""
|
||||
|
||||
def test_create_access_token(self):
|
||||
"""Test JWT token creation"""
|
||||
token = create_access_token(data={"sub": "testuser"}, expires_delta=timedelta(minutes=30))
|
||||
assert isinstance(token, str)
|
||||
# JWT tokens have 3 parts separated by dots
|
||||
assert len(token.split(".")) == 3
|
||||
|
||||
def test_create_token_default_expiration(self):
|
||||
"""Test token creation with default expiration"""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
assert isinstance(token, str)
|
||||
|
||||
def test_verify_token_valid(self):
|
||||
"""Test verifying a valid token"""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
payload = verify_token(token)
|
||||
assert payload is not None
|
||||
assert payload.get("sub") == "testuser"
|
||||
|
||||
def test_verify_token_invalid(self):
|
||||
"""Test verifying an invalid token"""
|
||||
payload = verify_token("invalid.token.here")
|
||||
assert payload is None
|
||||
|
||||
def test_verify_token_expired(self):
|
||||
"""Test verifying an expired token"""
|
||||
# Create a token that's already expired
|
||||
token = create_access_token(
|
||||
data={"sub": "testuser"},
|
||||
expires_delta=timedelta(seconds=-1) # Expired
|
||||
)
|
||||
payload = verify_token(token)
|
||||
# Should return None for expired token
|
||||
assert payload is None
|
||||
|
||||
def test_token_contains_username(self):
|
||||
"""Test that token contains the username in 'sub' claim"""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
payload = verify_token(token)
|
||||
assert payload["sub"] == "testuser"
|
||||
|
||||
def test_token_with_custom_claims(self):
|
||||
"""Test token creation with custom claims"""
|
||||
token = create_access_token(data={"sub": "testuser", "role": "admin"})
|
||||
payload = verify_token(token)
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["role"] == "admin"
|
||||
|
||||
def test_get_user_from_token_valid(self):
|
||||
"""Test getting user from valid token"""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
username = get_user_from_token(token)
|
||||
assert username == "testuser"
|
||||
|
||||
def test_get_user_from_token_invalid(self):
|
||||
"""Test getting user from invalid token"""
|
||||
username = get_user_from_token("invalid.token")
|
||||
assert username is None
|
||||
|
||||
def test_get_user_from_token_no_sub(self):
|
||||
"""Test getting user from token without 'sub' claim"""
|
||||
# Create token without 'sub' claim
|
||||
token = create_access_token(data={"user": "testuser"})
|
||||
username = get_user_from_token(token)
|
||||
assert username is None
|
||||
|
||||
def test_different_secrets(self):
|
||||
"""Test that tokens can't be verified with different secrets"""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
|
||||
# Try to verify with different secret (by mocking)
|
||||
with patch('app.auth.JWT_SECRET_KEY', 'different-secret'):
|
||||
payload = verify_token(token)
|
||||
# Should fail verification
|
||||
assert payload is None
|
||||
|
||||
|
||||
class TestTokenExpiration:
|
||||
"""Tests for token expiration handling"""
|
||||
|
||||
def test_token_expiration_time(self):
|
||||
"""Test that token expiration time is correct"""
|
||||
from app.auth import ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
# Create token with custom expiration
|
||||
expires = timedelta(minutes=30)
|
||||
token = create_access_token(data={"sub": "testuser"}, expires_delta=expires)
|
||||
# Token should be valid immediately
|
||||
payload = verify_token(token)
|
||||
assert payload is not None
|
||||
|
||||
def test_default_expiration_from_config(self):
|
||||
"""Test that default expiration matches configuration"""
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
# Just verify the setting exists
|
||||
assert hasattr(settings, 'ACCESS_TOKEN_EXPIRE_MINUTES') or 'ACCESS_TOKEN_EXPIRE_MINUTES' in dir(settings)
|
||||
|
||||
|
||||
class TestPasswordSecurity:
|
||||
"""Tests for password handling security"""
|
||||
|
||||
def test_password_not_stored_plaintext(self, user_manager):
|
||||
"""Test that passwords are never stored in plain text"""
|
||||
user_manager.create_user("testuser", "password123")
|
||||
user_data = user_manager.users["testuser"]
|
||||
assert "password" not in user_data
|
||||
assert "password_hash" in user_data
|
||||
assert user_data["password_hash"] != "password123"
|
||||
|
||||
def test_password_case_sensitive(self, user_manager):
|
||||
"""Test that password authentication is case-sensitive"""
|
||||
user_manager.create_user("testuser", "Password123")
|
||||
# Wrong case should fail
|
||||
user = user_manager.authenticate_user("testuser", "password123")
|
||||
assert user is None
|
||||
|
||||
def test_different_users_same_password(self, user_manager):
|
||||
"""Test that different users with same password have different hashes"""
|
||||
# Bcrypt uses salt, so hashes should be different
|
||||
user1 = user_manager.create_user("user1", "samepassword")
|
||||
user2 = user_manager.create_user("user2", "samepassword")
|
||||
assert user1["password_hash"] != user2["password_hash"]
|
||||
|
||||
def test_password_hash_algorithm(self, user_manager):
|
||||
"""Test that bcrypt is used for password hashing"""
|
||||
user = user_manager.create_user("testuser", "password123")
|
||||
# Bcrypt hashes start with $2b$
|
||||
assert user["password_hash"].startswith("$2b$")
|
||||
|
||||
|
||||
class TestUserDataPersistence:
|
||||
"""Tests for user data persistence and file operations"""
|
||||
|
||||
@pytest.fixture
|
||||
def user_manager_with_file(self, temp_dir):
|
||||
"""Create a UserManager and allow file operations"""
|
||||
users_file = temp_dir / "test_users.json"
|
||||
manager = UserManager(json_path=str(users_file))
|
||||
yield manager
|
||||
if users_file.exists():
|
||||
users_file.unlink()
|
||||
|
||||
def test_user_saved_to_file(self, user_manager_with_file, temp_dir):
|
||||
"""Test that users are saved to file"""
|
||||
users_file = temp_dir / "test_users.json"
|
||||
manager = user_manager_with_file
|
||||
|
||||
manager.create_user("testuser", "password123")
|
||||
|
||||
# Read file directly
|
||||
data = json.loads(users_file.read_text())
|
||||
assert "testuser" in data["users"]
|
||||
|
||||
def test_multiple_users_persisted(self, user_manager_with_file, temp_dir):
|
||||
"""Test that multiple users are persisted correctly"""
|
||||
users_file = temp_dir / "test_users.json"
|
||||
manager = user_manager_with_file
|
||||
|
||||
manager.create_user("user1", "password1")
|
||||
manager.create_user("user2", "password2")
|
||||
manager.create_user("user3", "password3")
|
||||
|
||||
data = json.loads(users_file.read_text())
|
||||
assert len(data["users"]) == 3
|
||||
assert "user1" in data["users"]
|
||||
assert "user2" in data["users"]
|
||||
assert "user3" in data["users"]
|
||||
|
||||
def test_user_data_has_required_fields(self, user_manager_with_file):
|
||||
"""Test that user data contains all required fields"""
|
||||
manager = user_manager_with_file
|
||||
user = manager.create_user("testuser", "password123")
|
||||
|
||||
required_fields = ["username", "password_hash", "created_at", "last_login"]
|
||||
for field in required_fields:
|
||||
assert field in user
|
||||
|
||||
def test_created_at_is_iso_format(self, user_manager_with_file):
|
||||
"""Test that created_at is in ISO format"""
|
||||
manager = user_manager_with_file
|
||||
user = manager.create_user("testuser", "password123")
|
||||
# Should be parseable as ISO datetime
|
||||
datetime.fromisoformat(user["created_at"])
|
||||
|
||||
|
||||
class TestUsernameValidation:
|
||||
"""Tests for username validation"""
|
||||
|
||||
@pytest.fixture
|
||||
def user_manager(self, temp_dir):
|
||||
users_file = temp_dir / "users.json"
|
||||
manager = UserManager(json_path=str(users_file))
|
||||
yield manager
|
||||
if users_file.exists():
|
||||
users_file.unlink()
|
||||
|
||||
def test_username_case_sensitive(self, user_manager):
|
||||
"""Test that usernames are case-sensitive"""
|
||||
user_manager.create_user("TestUser", "password123")
|
||||
# Different case should be treated as different user
|
||||
user2 = user_manager.create_user("testuser", "password456")
|
||||
assert user2["username"] == "testuser"
|
||||
# Both should exist
|
||||
assert "TestUser" in user_manager.users
|
||||
assert "testuser" in user_manager.users
|
||||
|
||||
def test_username_with_special_chars(self, user_manager):
|
||||
"""Test usernames with special characters"""
|
||||
# Should accept most characters
|
||||
user = user_manager.create_user("user-123", "password123")
|
||||
assert user["username"] == "user-123"
|
||||
|
||||
def test_username_with_spaces(self, user_manager):
|
||||
"""Test usernames with spaces"""
|
||||
user = user_manager.create_user("test user", "password123")
|
||||
assert user["username"] == "test user"
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Unit tests for utility functions (app/utils.py)
|
||||
Tests filename sanitization and security validation
|
||||
"""
|
||||
import pytest
|
||||
from app.utils import sanitize_filename, is_safe_filename
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
"""Tests for sanitize_filename function"""
|
||||
|
||||
def test_sanitize_simple_filename(self):
|
||||
"""Test sanitizing a simple, safe filename"""
|
||||
filename = "simple_video.mp4"
|
||||
result = sanitize_filename(filename)
|
||||
assert result == "simple_video.mp4"
|
||||
|
||||
def test_sanitize_with_dangerous_chars(self):
|
||||
"""Test removal of dangerous characters"""
|
||||
# Test each dangerous character
|
||||
assert sanitize_filename("file\\name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename("file/name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename("file:name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename("file*name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename("file?name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename('file"name.mp4') == "file_name.mp4"
|
||||
assert sanitize_filename("file<name>.mp4") == "file_name_.mp4"
|
||||
assert sanitize_filename("file|name.mp4") == "file_name.mp4"
|
||||
|
||||
def test_sanitize_all_dangerous_chars(self):
|
||||
"""Test filename with all dangerous characters"""
|
||||
filename = 'file\\/:*?"<>|name.mp4'
|
||||
result = sanitize_filename(filename)
|
||||
assert result == "file________name.mp4"
|
||||
|
||||
def test_sanitize_path_traversal(self):
|
||||
"""Test path traversal attempts are blocked"""
|
||||
# Parent directory traversal
|
||||
assert sanitize_filename("../../../etc/passwd") == "______etc_passwd"
|
||||
assert sanitize_filename("../../secret.txt") == "____secret.txt"
|
||||
|
||||
# Current directory reference
|
||||
assert sanitize_filename("./file.txt") == "file.txt"
|
||||
assert sanitize_filename(".hidden") == "hidden"
|
||||
|
||||
# Absolute path attempts
|
||||
assert sanitize_filename("/etc/passwd") == "passwd"
|
||||
assert sanitize_filename("\\windows\\system32") == "system32"
|
||||
|
||||
def test_sanitize_leading_dots_and_dashes(self):
|
||||
"""Test removal of leading dots and dashes"""
|
||||
assert sanitize_filename(".hidden") == "hidden"
|
||||
assert sanitize_filename("..hidden") == "hidden"
|
||||
assert sanitize_filename("---file.txt") == "file.txt"
|
||||
assert sanitize_filename("...test...mp4") == "test...mp4" # Only leading
|
||||
|
||||
def test_sanitize_empty_filename(self):
|
||||
"""Test empty filename returns default"""
|
||||
assert sanitize_filename("") == "download"
|
||||
assert sanitize_filename(" ") == "download"
|
||||
|
||||
def test_sanitize_only_dangerous_chars(self):
|
||||
"""Test filename with only dangerous characters"""
|
||||
assert sanitize_filename("\\/:*?\"<>|") == "download"
|
||||
|
||||
def test_sanitize_length_limit(self):
|
||||
"""Test filename length is limited"""
|
||||
# Create a very long filename
|
||||
long_name = "a" * 300 + ".mp4"
|
||||
result = sanitize_filename(long_name, max_length=255)
|
||||
assert len(result) <= 255
|
||||
assert result.endswith(".mp4")
|
||||
|
||||
def test_sanitize_length_limit_preserves_extension(self):
|
||||
"""Test that extension is preserved when limiting length"""
|
||||
long_name = "x" * 260 + ".mp4"
|
||||
result = sanitize_filename(long_name, max_length=255)
|
||||
assert result.endswith(".mp4")
|
||||
# Name part is truncated but extension kept
|
||||
name, ext = result.rsplit(".", 1)
|
||||
assert len(name) + len(ext) + 1 == 255
|
||||
|
||||
def test_sanitize_unicode(self):
|
||||
"""Test sanitization with unicode characters"""
|
||||
# Japanese characters
|
||||
assert sanitize_filename("アニメ.mp4") == "アニメ.mp4"
|
||||
# Accented characters
|
||||
assert sanitize_filename("café.mp4") == "café.mp4"
|
||||
# Emoji
|
||||
assert sanitize_filename("video🎬.mp4") == "video🎬.mp4"
|
||||
|
||||
def test_sanitize_multiple_extensions(self):
|
||||
"""Test filename with multiple dots"""
|
||||
assert sanitize_filename("file.name.with.dots.tar.gz") == "file.name.with.dots.tar.gz"
|
||||
# Only the last part is used for extension in length limit
|
||||
|
||||
def test_sanitize_no_extension(self):
|
||||
"""Test filename without extension"""
|
||||
assert sanitize_filename("README") == "README"
|
||||
assert sanitize_filename("file\\name") == "file_name"
|
||||
|
||||
def test_sanitize_custom_max_length(self):
|
||||
"""Test custom max length parameter"""
|
||||
filename = "very_long_filename_here.txt"
|
||||
result = sanitize_filename(filename, max_length=10)
|
||||
assert len(result) <= 10
|
||||
# Truncates name but keeps extension
|
||||
assert result.endswith(".txt")
|
||||
|
||||
def test_sanitize_special_cases(self):
|
||||
"""Test various special cases"""
|
||||
# CON, PRN, AUX etc (Windows reserved names) - not handled currently
|
||||
# but we document behavior
|
||||
assert sanitize_filename("CON.txt") == "CON.txt"
|
||||
|
||||
# Filenames with spaces
|
||||
assert sanitize_filename("my video file.mp4") == "my video file.mp4"
|
||||
|
||||
# Mixed case
|
||||
assert sanitize_filename("ViDeO.Mp4") == "ViDeO.Mp4"
|
||||
|
||||
|
||||
class TestIsSafeFilename:
|
||||
"""Tests for is_safe_filename function"""
|
||||
|
||||
def test_safe_filenames(self):
|
||||
"""Test that safe filenames return True"""
|
||||
assert is_safe_filename("file.txt") is True
|
||||
assert is_safe_filename("my_video.mp4") is True
|
||||
assert is_safe_filename("document.pdf") is True
|
||||
assert is_safe_filename("archive.tar.gz") is True
|
||||
assert is_safe_filename("README") is True
|
||||
assert is_safe_filename("file with spaces.txt") is True
|
||||
assert is_safe_filename("file-with-dashes.txt") is True
|
||||
assert is_safe_filename("file_with_underscores.txt") is True
|
||||
|
||||
def test_unsafe_path_traversal(self):
|
||||
"""Test that path traversal attempts return False"""
|
||||
assert is_safe_filename("../etc/passwd") is False
|
||||
assert is_safe_filename("../../secret") is False
|
||||
assert is_safe_filename("../../../file.txt") is False
|
||||
assert is_safe_filename("....\\....\\file.txt") is False
|
||||
|
||||
def test_unsafe_absolute_paths(self):
|
||||
"""Test that absolute paths return False"""
|
||||
assert is_safe_filename("/etc/passwd") is False
|
||||
assert is_safe_filename("/var/log/file.txt") is False
|
||||
assert is_safe_filename("\\windows\\system32") is False
|
||||
assert is_safe_filename("\\\\network\\share") is False
|
||||
|
||||
def test_unsafe_current_directory(self):
|
||||
"""Test that current directory references return False"""
|
||||
assert is_safe_filename("./file.txt") is False
|
||||
assert is_safe_filename(".hidden") is False # Leading dot
|
||||
assert is_safe_filename("././file.txt") is False
|
||||
|
||||
def test_unsafe_windows_drives(self):
|
||||
"""Test that Windows drive letters return False"""
|
||||
assert is_safe_filename("C:\\file.txt") is False
|
||||
assert is_safe_filename("D:\\data\\file.txt") is False
|
||||
assert is_safe_filename("E:/file.txt") is False
|
||||
assert is_safe_filename("c:file.txt") is False
|
||||
|
||||
def test_empty_filename(self):
|
||||
"""Test that empty filename returns False"""
|
||||
assert is_safe_filename("") is False
|
||||
assert is_safe_filename(" ") is False
|
||||
|
||||
def test_mixed_slashes(self):
|
||||
"""Test mixed forward and backward slashes"""
|
||||
assert is_safe_filename("folder\\file/name.txt") is False
|
||||
assert is_safe_filename("folder/sub\\file.txt") is False
|
||||
|
||||
def test_unicode_safe(self):
|
||||
"""Test unicode filenames are considered safe if no path traversal"""
|
||||
assert is_safe_filename("ファイル.txt") is True
|
||||
assert is_safe_filename("café.txt") is True
|
||||
assert is_safe_filename("файл.txt") is True
|
||||
|
||||
def test_edge_cases(self):
|
||||
"""Test edge cases"""
|
||||
# Just a dot
|
||||
assert is_safe_filename(".") is False
|
||||
|
||||
# Multiple dots
|
||||
assert is_safe_filename("...") is False
|
||||
|
||||
# Dots in middle are OK
|
||||
assert is_safe_filename("file.name.txt") is True
|
||||
|
||||
# Slash at end
|
||||
assert is_safe_filename("file.txt/") is False
|
||||
|
||||
# Backslash at end
|
||||
assert is_safe_filename("file.txt\\") is False
|
||||
|
||||
# Spaces only
|
||||
assert is_safe_filename(" ") is False
|
||||
|
||||
|
||||
class TestUtilityIntegration:
|
||||
"""Integration tests for utility functions working together"""
|
||||
|
||||
def test_sanitize_then_is_safe(self):
|
||||
"""Test that sanitized filenames are always safe"""
|
||||
unsafe_filenames = [
|
||||
"../../../etc/passwd",
|
||||
"/absolute/path/file.txt",
|
||||
"C:\\windows\\file.txt",
|
||||
"./local/file.txt",
|
||||
".hidden",
|
||||
"file\\with:bad*chars?.txt",
|
||||
]
|
||||
|
||||
for filename in unsafe_filenames:
|
||||
sanitized = sanitize_filename(filename)
|
||||
assert is_safe_filename(sanitized), f"Sanitized '{filename}' -> '{sanitized}' is not safe"
|
||||
|
||||
def test_roundtrip_safe_filenames(self):
|
||||
"""Test that safe filenames remain unchanged"""
|
||||
safe_filenames = [
|
||||
"file.txt",
|
||||
"my_video.mp4",
|
||||
"document.pdf",
|
||||
"archive.tar.gz",
|
||||
"README",
|
||||
"file with spaces.txt",
|
||||
]
|
||||
|
||||
for filename in safe_filenames:
|
||||
sanitized = sanitize_filename(filename)
|
||||
assert sanitized == filename, f"Safe filename '{filename}' was changed to '{sanitized}'"
|
||||
|
||||
def test_empty_string_handling(self):
|
||||
"""Test that empty string is handled consistently"""
|
||||
sanitized = sanitize_filename("")
|
||||
assert sanitized == "download"
|
||||
assert is_safe_filename(sanitized) is True
|
||||
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Unit tests for Watchlist system (app/watchlist.py, app/models/watchlist.py)
|
||||
Tests watchlist CRUD operations, episode checking, and scheduler
|
||||
"""
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from app.watchlist import WatchlistManager
|
||||
from app.models.watchlist import (
|
||||
WatchlistItem,
|
||||
WatchlistItemCreate,
|
||||
WatchlistItemUpdate,
|
||||
WatchlistStatus,
|
||||
QualityPreference,
|
||||
WatchlistSettings
|
||||
)
|
||||
|
||||
|
||||
class TestWatchlistManager:
|
||||
"""Tests for WatchlistManager class"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_watchlist_file(self, temp_dir):
|
||||
"""Create a temporary watchlist.json file"""
|
||||
return temp_dir / "watchlist.json"
|
||||
|
||||
@pytest.fixture
|
||||
def watchlist_manager(self, temp_watchlist_file):
|
||||
"""Create a WatchlistManager instance with temporary storage"""
|
||||
manager = WatchlistManager(json_path=str(temp_watchlist_file))
|
||||
yield manager
|
||||
# Cleanup
|
||||
if temp_watchlist_file.exists():
|
||||
temp_watchlist_file.unlink()
|
||||
|
||||
@pytest.fixture
|
||||
def sample_watchlist_item(self):
|
||||
"""Create a sample watchlist item"""
|
||||
return WatchlistItemCreate(
|
||||
anime_url="https://anime-sama.si/catalogue/test/s1/vostfr/",
|
||||
anime_title="Test Anime",
|
||||
provider="anime-sama",
|
||||
lang="vostfr",
|
||||
quality_preference=QualityPreference.AUTO,
|
||||
auto_download=True
|
||||
)
|
||||
|
||||
def test_watchlist_manager_init_creates_file(self, watchlist_manager, temp_watchlist_file):
|
||||
"""Test that WatchlistManager creates the file on init"""
|
||||
assert temp_watchlist_file.exists()
|
||||
data = json.loads(temp_watchlist_file.read_text())
|
||||
assert "items" in data
|
||||
|
||||
def test_add_item_success(self, watchlist_manager, sample_watchlist_item):
|
||||
"""Test adding an item to watchlist"""
|
||||
item = watchlist_manager.add_item(
|
||||
user_id="test_user",
|
||||
item_data=sample_watchlist_item
|
||||
)
|
||||
assert item.id is not None
|
||||
assert item.anime_title == "Test Anime"
|
||||
assert item.status == WatchlistStatus.ACTIVE
|
||||
assert item.user_id == "test_user"
|
||||
|
||||
def test_add_item_duplicate(self, watchlist_manager, sample_watchlist_item):
|
||||
"""Test that duplicate items are rejected"""
|
||||
watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
|
||||
|
||||
def test_get_items_empty(self, watchlist_manager):
|
||||
"""Test getting items when watchlist is empty"""
|
||||
items = watchlist_manager.get_items("test_user")
|
||||
assert items == []
|
||||
|
||||
def test_get_items_with_data(self, watchlist_manager, sample_watchlist_item):
|
||||
"""Test getting items after adding one"""
|
||||
watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
|
||||
items = watchlist_manager.get_items("test_user")
|
||||
assert len(items) == 1
|
||||
assert items[0].anime_title == "Test Anime"
|
||||
|
||||
def test_get_items_by_status(self, watchlist_manager):
|
||||
"""Test filtering items by status"""
|
||||
from app.models.watchlist import WatchlistItemCreate
|
||||
|
||||
# Add items with different statuses
|
||||
item1 = WatchlistItemCreate(
|
||||
anime_url="https://anime-sama.si/test1/",
|
||||
anime_title="Anime 1",
|
||||
provider="anime-sama",
|
||||
lang="vostfr"
|
||||
)
|
||||
item2 = WatchlistItemCreate(
|
||||
anime_url="https://anime-sama.si/test2/",
|
||||
anime_title="Anime 2",
|
||||
provider="anime-sama",
|
||||
lang="vostfr"
|
||||
)
|
||||
|
||||
watchlist_manager.add_item(user_id="test_user", item_data=item1)
|
||||
item2_id = watchlist_manager.add_item(user_id="test_user", item_data=item2).id
|
||||
|
||||
# Pause one item
|
||||
watchlist_manager.update_item(
|
||||
user_id="test_user",
|
||||
item_id=item2_id,
|
||||
item_data=WatchlistItemUpdate(status=WatchlistStatus.PAUSED)
|
||||
)
|
||||
|
||||
# Get only active items
|
||||
active_items = watchlist_manager.get_items("test_user", status=WatchlistStatus.ACTIVE)
|
||||
assert len(active_items) == 1
|
||||
assert active_items[0].anime_title == "Anime 1"
|
||||
|
||||
# Get only paused items
|
||||
paused_items = watchlist_manager.get_items("test_user", status=WatchlistStatus.PAUSED)
|
||||
assert len(paused_items) == 1
|
||||
assert paused_items[0].anime_title == "Anime 2"
|
||||
|
||||
def test_get_item_by_id(self, watchlist_manager, sample_watchlist_item):
|
||||
"""Test getting a specific item by ID"""
|
||||
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
|
||||
retrieved = watchlist_manager.get_item(user_id="test_user", item_id=item.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == item.id
|
||||
assert retrieved.anime_title == "Test Anime"
|
||||
|
||||
def test_get_item_by_id_not_found(self, watchlist_manager):
|
||||
"""Test getting non-existent item"""
|
||||
item = watchlist_manager.get_item(user_id="test_user", item_id="nonexistent")
|
||||
assert item is None
|
||||
|
||||
def test_update_item(self, watchlist_manager, sample_watchlist_item):
|
||||
"""Test updating an item"""
|
||||
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
|
||||
|
||||
updated = watchlist_manager.update_item(
|
||||
user_id="test_user",
|
||||
item_id=item.id,
|
||||
item_data=WatchlistItemUpdate(
|
||||
quality_preference=QualityPreference.FULLHD
|
||||
)
|
||||
)
|
||||
|
||||
assert updated.quality_preference == QualityPreference.FULLHD
|
||||
assert updated.anime_title == "Test Anime" # Unchanged
|
||||
|
||||
def test_update_item_not_found(self, watchlist_manager):
|
||||
"""Test updating non-existent item"""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
watchlist_manager.update_item(
|
||||
user_id="test_user",
|
||||
item_id="nonexistent",
|
||||
item_data=WatchlistItemUpdate()
|
||||
)
|
||||
|
||||
def test_delete_item(self, watchlist_manager, sample_watchlist_item):
|
||||
"""Test deleting an item"""
|
||||
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
|
||||
watchlist_manager.delete_item(user_id="test_user", item_id=item.id)
|
||||
|
||||
# Should be deleted
|
||||
items = watchlist_manager.get_items("test_user")
|
||||
assert len(items) == 0
|
||||
|
||||
def test_delete_item_not_found(self, watchlist_manager):
|
||||
"""Test deleting non-existent item"""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
watchlist_manager.delete_item(user_id="test_user", item_id="nonexistent")
|
||||
|
||||
def test_pause_item(self, watchlist_manager, sample_watchlist_item):
|
||||
"""Test pausing an item"""
|
||||
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
|
||||
paused = watchlist_manager.pause_item(user_id="test_user", item_id=item.id)
|
||||
|
||||
assert paused.status == WatchlistStatus.PAUSED
|
||||
|
||||
def test_resume_item(self, watchlist_manager, sample_watchlist_item):
|
||||
"""Test resuming a paused item"""
|
||||
item = watchlist_manager.add_item(user_id="test_user", item_data=sample_watchlist_item)
|
||||
# Pause first
|
||||
watchlist_manager.pause_item(user_id="test_user", item_id=item.id)
|
||||
|
||||
# Resume
|
||||
resumed = watchlist_manager.resume_item(user_id="test_user", item_id=item.id)
|
||||
assert resumed.status == WatchlistStatus.ACTIVE
|
||||
|
||||
def test_get_stats(self, watchlist_manager):
|
||||
"""Test getting watchlist statistics"""
|
||||
from app.models.watchlist import WatchlistItemCreate
|
||||
|
||||
# Add multiple items
|
||||
for i in range(3):
|
||||
item = WatchlistItemCreate(
|
||||
anime_url=f"https://anime-sama.si/test{i}/",
|
||||
anime_title=f"Anime {i}",
|
||||
provider="anime-sama",
|
||||
lang="vostfr"
|
||||
)
|
||||
watchlist_manager.add_item(user_id="test_user", item_data=item)
|
||||
|
||||
stats = watchlist_manager.get_stats("test_user")
|
||||
assert stats["total"] == 3
|
||||
assert stats["by_status"]["active"] == 3
|
||||
|
||||
def test_multi_user_isolation(self, watchlist_manager):
|
||||
"""Test that different users have separate watchlists"""
|
||||
from app.models.watchlist import WatchlistItemCreate
|
||||
|
||||
item1 = WatchlistItemCreate(
|
||||
anime_url="https://anime-sama.si/test1/",
|
||||
anime_title="Anime 1",
|
||||
provider="anime-sama",
|
||||
lang="vostfr"
|
||||
)
|
||||
item2 = WatchlistItemCreate(
|
||||
anime_url="https://anime-sama.si/test2/",
|
||||
anime_title="Anime 2",
|
||||
provider="anime-sama",
|
||||
lang="vostfr"
|
||||
)
|
||||
|
||||
watchlist_manager.add_item(user_id="user1", item_data=item1)
|
||||
watchlist_manager.add_item(user_id="user2", item_data=item2)
|
||||
|
||||
# Each user should only see their own items
|
||||
user1_items = watchlist_manager.get_items("user1")
|
||||
user2_items = watchlist_manager.get_items("user2")
|
||||
|
||||
assert len(user1_items) == 1
|
||||
assert len(user2_items) == 1
|
||||
assert user1_items[0].anime_title == "Anime 1"
|
||||
assert user2_items[0].anime_title == "Anime 2"
|
||||
|
||||
|
||||
class TestWatchlistItemModel:
|
||||
"""Tests for WatchlistItem Pydantic model"""
|
||||
|
||||
def test_watchlist_item_creation(self):
|
||||
"""Test creating a WatchlistItem"""
|
||||
item = WatchlistItem(
|
||||
id="test-id",
|
||||
user_id="test_user",
|
||||
anime_url="https://anime-sama.si/test/",
|
||||
anime_title="Test Anime",
|
||||
provider="anime-sama",
|
||||
lang="vostfr",
|
||||
quality_preference=QualityPreference.AUTO,
|
||||
auto_download=True,
|
||||
status=WatchlistStatus.ACTIVE,
|
||||
last_checked=None,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
assert item.anime_title == "Test Anime"
|
||||
assert item.status == WatchlistStatus.ACTIVE
|
||||
|
||||
def test_quality_preference_enum(self):
|
||||
"""Test QualityPreference enum values"""
|
||||
assert QualityPreference.AUTO == "auto"
|
||||
assert QualityPreference.FULLHD == "1080p"
|
||||
assert QualityPreference.HD == "720p"
|
||||
assert QualityPreference.SD == "480p"
|
||||
|
||||
def test_watchlist_status_enum(self):
|
||||
"""Test WatchlistStatus enum values"""
|
||||
assert WatchlistStatus.ACTIVE == "active"
|
||||
assert WatchlistStatus.PAUSED == "paused"
|
||||
assert WatchlistStatus.COMPLETED == "completed"
|
||||
assert WatchlistStatus.ARCHIVED == "archived"
|
||||
|
||||
|
||||
class TestWatchlistSettings:
|
||||
"""Tests for WatchlistSettings model and management"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_settings_file(self, temp_dir):
|
||||
"""Create a temporary watchlist_settings.json file"""
|
||||
return temp_dir / "watchlist_settings.json"
|
||||
|
||||
def test_watchlist_settings_defaults(self):
|
||||
"""Test default values for WatchlistSettings"""
|
||||
settings = WatchlistSettings()
|
||||
assert settings.auto_download_enabled is True
|
||||
assert settings.check_interval_hours >= 1
|
||||
assert settings.check_interval_hours <= 168
|
||||
|
||||
def test_watchlist_settings_validation(self):
|
||||
"""Test WatchlistSettings validation"""
|
||||
# Valid settings
|
||||
settings = WatchlistSettings(
|
||||
auto_download_enabled=True,
|
||||
check_interval_hours=24,
|
||||
default_quality=QualityPreference.AUTO
|
||||
)
|
||||
assert settings.check_interval_hours == 24
|
||||
|
||||
def test_watchlist_settings_invalid_interval(self):
|
||||
"""Test that invalid check intervals are rejected"""
|
||||
# Less than 1 hour
|
||||
with pytest.raises(ValueError):
|
||||
WatchlistSettings(check_interval_hours=0)
|
||||
|
||||
# More than 168 hours (1 week)
|
||||
with pytest.raises(ValueError):
|
||||
WatchlistSettings(check_interval_hours=200)
|
||||
|
||||
|
||||
class TestEpisodeChecker:
|
||||
"""Tests for EpisodeChecker functionality"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_new_episodes(self):
|
||||
"""Test checking for new episodes"""
|
||||
from app.episode_checker import EpisodeChecker
|
||||
|
||||
# Mock the downloader
|
||||
with patch('app.episode_checker.get_downloader') as mock_get_downloader:
|
||||
mock_downloader = AsyncMock()
|
||||
mock_downloader.get_episodes.return_value = [
|
||||
{"episode_number": 1, "url": "ep1"},
|
||||
{"episode_number": 2, "url": "ep2"},
|
||||
{"episode_number": 3, "url": "ep3"}
|
||||
]
|
||||
mock_get_downloader.return_value = mock_downloader
|
||||
|
||||
checker = EpisodeChecker()
|
||||
# Test episode checking logic
|
||||
episodes = await mock_downloader.get_episodes(
|
||||
"https://anime-sama.si/test/",
|
||||
"vostfr"
|
||||
)
|
||||
assert len(episodes) == 3
|
||||
assert episodes[2]["episode_number"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_episode_download_creation(self):
|
||||
"""Test that new episodes trigger downloads when auto_download is enabled"""
|
||||
# This would test the integration with download_manager
|
||||
# For now, just test the logic flow
|
||||
pass
|
||||
|
||||
|
||||
class TestAutoDownloadScheduler:
|
||||
"""Tests for AutoDownloadScheduler functionality"""
|
||||
|
||||
def test_scheduler_initialization(self):
|
||||
"""Test scheduler initialization"""
|
||||
from app.auto_download_scheduler import AutoDownloadScheduler
|
||||
scheduler = AutoDownloadScheduler()
|
||||
assert scheduler.is_running() is False
|
||||
|
||||
def test_scheduler_start_stop(self):
|
||||
"""Test starting and stopping scheduler"""
|
||||
from app.auto_download_scheduler import AutoDownloadScheduler
|
||||
scheduler = AutoDownloadScheduler()
|
||||
|
||||
# Start
|
||||
scheduler.start()
|
||||
assert scheduler.is_running() is True
|
||||
|
||||
# Stop
|
||||
scheduler.stop()
|
||||
assert scheduler.is_running() is False
|
||||
|
||||
def test_scheduler_interval_validation(self):
|
||||
"""Test that scheduler validates intervals"""
|
||||
from app.auto_download_scheduler import AutoDownloadScheduler
|
||||
scheduler = AutoDownloadScheduler()
|
||||
|
||||
# Valid interval
|
||||
scheduler.set_interval(24) # 24 hours
|
||||
assert scheduler.get_interval() == 24
|
||||
|
||||
# Invalid interval (should raise or clamp)
|
||||
with pytest.raises(ValueError):
|
||||
scheduler.set_interval(0) # Too small
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
scheduler.set_interval(200) # Too large
|
||||
|
||||
|
||||
class TestWatchlistIntegration:
|
||||
"""Integration tests for watchlist system"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_watchlist_file(self, temp_dir):
|
||||
"""Create a temporary watchlist.json file"""
|
||||
return temp_dir / "watchlist.json"
|
||||
|
||||
@pytest.fixture
|
||||
def watchlist_manager(self, temp_watchlist_file):
|
||||
"""Create a WatchlistManager instance"""
|
||||
manager = WatchlistManager(json_path=str(temp_watchlist_file))
|
||||
yield manager
|
||||
if temp_watchlist_file.exists():
|
||||
temp_watchlist_file.unlink()
|
||||
|
||||
def test_full_workflow(self, watchlist_manager):
|
||||
"""Test complete workflow: add -> pause -> resume -> delete"""
|
||||
from app.models.watchlist import WatchlistItemCreate
|
||||
|
||||
# Add
|
||||
item_data = WatchlistItemCreate(
|
||||
anime_url="https://anime-sama.si/test/",
|
||||
anime_title="Test Anime",
|
||||
provider="anime-sama",
|
||||
lang="vostfr"
|
||||
)
|
||||
item = watchlist_manager.add_item(user_id="test_user", item_data=item_data)
|
||||
assert item.status == WatchlistStatus.ACTIVE
|
||||
|
||||
# Pause
|
||||
paused = watchlist_manager.pause_item(user_id="test_user", item_id=item.id)
|
||||
assert paused.status == WatchlistStatus.PAUSED
|
||||
|
||||
# Resume
|
||||
resumed = watchlist_manager.resume_item(user_id="test_user", item_id=item.id)
|
||||
assert resumed.status == WatchlistStatus.ACTIVE
|
||||
|
||||
# Delete
|
||||
watchlist_manager.delete_item(user_id="test_user", item_id=item.id)
|
||||
items = watchlist_manager.get_items("test_user")
|
||||
assert len(items) == 0
|
||||
|
||||
def test_update_quality_preference_workflow(self, watchlist_manager):
|
||||
"""Test updating quality preference"""
|
||||
from app.models.watchlist import WatchlistItemCreate
|
||||
|
||||
item_data = WatchlistItemCreate(
|
||||
anime_url="https://anime-sama.si/test/",
|
||||
anime_title="Test Anime",
|
||||
provider="anime-sama",
|
||||
lang="vostfr",
|
||||
quality_preference=QualityPreference.AUTO
|
||||
)
|
||||
item = watchlist_manager.add_item(user_id="test_user", item_data=item_data)
|
||||
|
||||
# Update to 1080p
|
||||
updated = watchlist_manager.update_item(
|
||||
user_id="test_user",
|
||||
item_id=item.id,
|
||||
item_data=WatchlistItemUpdate(quality_preference=QualityPreference.FULLHD)
|
||||
)
|
||||
assert updated.quality_preference == QualityPreference.FULLHD
|
||||
|
||||
def test_filter_by_status_workflow(self, watchlist_manager):
|
||||
"""Test filtering items by different statuses"""
|
||||
from app.models.watchlist import WatchlistItemCreate
|
||||
|
||||
# Add multiple items
|
||||
for i, status in enumerate([WatchlistStatus.ACTIVE, WatchlistStatus.PAUSED, WatchlistStatus.COMPLETED]):
|
||||
item_data = WatchlistItemCreate(
|
||||
anime_url=f"https://anime-sama.si/test{i}/",
|
||||
anime_title=f"Anime {i}",
|
||||
provider="anime-sama",
|
||||
lang="vostfr"
|
||||
)
|
||||
item = watchlist_manager.add_item(user_id="test_user", item_data=item_data)
|
||||
# Update status
|
||||
watchlist_manager.update_item(
|
||||
user_id="test_user",
|
||||
item_id=item.id,
|
||||
item_data=WatchlistItemUpdate(status=status)
|
||||
)
|
||||
|
||||
# Count by status
|
||||
stats = watchlist_manager.get_stats("test_user")
|
||||
assert stats["total"] == 3
|
||||
assert stats["by_status"]["active"] == 1
|
||||
assert stats["by_status"]["paused"] == 1
|
||||
assert stats["by_status"]["completed"] == 1
|
||||
Reference in New Issue
Block a user