Aniworld/tests/unit/test_validators.py
Lukas 014e22390e style: Apply formatting to infrastructure.md and test_validators.py
- Fix markdown table alignment in infrastructure.md
- Sort imports alphabetically in test_validators.py (auto-formatted)
2025-11-28 15:48:49 +01:00

534 lines
20 KiB
Python

"""
Unit tests for data validation utilities.
Tests the validators module in src/server/utils/validators.py.
"""
import pytest
from src.server.utils.validators import (
ValidatorMixin,
sanitize_filename,
validate_anime_url,
validate_backup_name,
validate_config_data,
validate_download_priority,
validate_download_quality,
validate_episode_range,
validate_ip_address,
validate_jwt_token,
validate_language,
validate_series_key,
validate_series_key_or_folder,
validate_series_name,
validate_websocket_message,
)
class TestValidateSeriesKey:
"""Tests for validate_series_key function."""
def test_valid_simple_key(self):
"""Test valid simple key."""
assert validate_series_key("naruto") == "naruto"
def test_valid_hyphenated_key(self):
"""Test valid hyphenated key."""
assert validate_series_key("attack-on-titan") == "attack-on-titan"
def test_valid_numeric_key(self):
"""Test valid key with numbers."""
assert validate_series_key("one-piece-2024") == "one-piece-2024"
def test_valid_key_starting_with_number(self):
"""Test valid key starting with number."""
assert validate_series_key("86-eighty-six") == "86-eighty-six"
def test_strips_whitespace(self):
"""Test that whitespace is stripped."""
assert validate_series_key(" naruto ") == "naruto"
def test_empty_string_raises(self):
"""Test empty string raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_series_key("")
def test_none_raises(self):
"""Test None raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_series_key(None)
def test_whitespace_only_raises(self):
"""Test whitespace-only string raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_series_key(" ")
def test_uppercase_raises(self):
"""Test uppercase letters raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("Attack-On-Titan")
def test_spaces_raises(self):
"""Test spaces raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack on titan")
def test_underscores_raises(self):
"""Test underscores raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack_on_titan")
def test_special_characters_raises(self):
"""Test special characters raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack@titan")
def test_leading_hyphen_raises(self):
"""Test leading hyphen raises ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("-attack-on-titan")
def test_trailing_hyphen_raises(self):
"""Test trailing hyphen raises ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack-on-titan-")
def test_consecutive_hyphens_raises(self):
"""Test consecutive hyphens raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack--on--titan")
def test_key_too_long_raises(self):
"""Test key exceeding 255 chars raises ValueError."""
long_key = "a" * 256
with pytest.raises(ValueError, match="255 characters"):
validate_series_key(long_key)
def test_max_length_key(self):
"""Test key at exactly 255 chars is valid."""
max_key = "a" * 255
assert validate_series_key(max_key) == max_key
class TestValidateSeriesKeyOrFolder:
"""Tests for validate_series_key_or_folder function."""
def test_valid_key_returns_key_true(self):
"""Test valid key returns (key, True)."""
result = validate_series_key_or_folder("attack-on-titan")
assert result == ("attack-on-titan", True)
def test_valid_folder_returns_folder_false(self):
"""Test valid folder returns (folder, False)."""
result = validate_series_key_or_folder("Attack on Titan (2013)")
assert result == ("Attack on Titan (2013)", False)
def test_empty_string_raises(self):
"""Test empty string raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_series_key_or_folder("")
def test_none_raises(self):
"""Test None raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_series_key_or_folder(None)
def test_whitespace_only_raises(self):
"""Test whitespace-only string raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_series_key_or_folder(" ")
def test_folder_not_allowed_raises(self):
"""Test folder format raises when not allowed."""
with pytest.raises(ValueError, match="Invalid series key format"):
validate_series_key_or_folder(
"Attack on Titan (2013)", allow_folder=False
)
def test_key_allowed_when_folder_disabled(self):
"""Test valid key works when folder is disabled."""
result = validate_series_key_or_folder(
"attack-on-titan", allow_folder=False
)
assert result == ("attack-on-titan", True)
def test_strips_whitespace(self):
"""Test that whitespace is stripped."""
result = validate_series_key_or_folder(" attack-on-titan ")
assert result == ("attack-on-titan", True)
def test_folder_too_long_raises(self):
"""Test folder exceeding 1000 chars raises ValueError."""
long_folder = "A" * 1001
with pytest.raises(ValueError, match="too long"):
validate_series_key_or_folder(long_folder)
class TestValidateSeriesName:
"""Tests for validate_series_name function."""
def test_valid_name(self):
"""Test valid series name."""
assert validate_series_name("Attack on Titan") == "Attack on Titan"
def test_strips_whitespace(self):
"""Test whitespace is stripped."""
assert validate_series_name(" Naruto ") == "Naruto"
def test_empty_raises(self):
"""Test empty name raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_series_name("")
def test_too_long_raises(self):
"""Test name over 200 chars raises ValueError."""
with pytest.raises(ValueError, match="too long"):
validate_series_name("A" * 201)
def test_invalid_chars_raises(self):
"""Test invalid characters raise ValueError."""
with pytest.raises(ValueError, match="invalid character"):
validate_series_name("Attack: Titan")
class TestValidateEpisodeRange:
"""Tests for validate_episode_range function."""
def test_valid_range(self):
"""Test valid episode range."""
assert validate_episode_range(1, 10) == (1, 10)
def test_same_start_end(self):
"""Test start equals end is valid."""
assert validate_episode_range(5, 5) == (5, 5)
def test_start_less_than_one_raises(self):
"""Test start less than 1 raises ValueError."""
with pytest.raises(ValueError, match="at least 1"):
validate_episode_range(0, 10)
def test_end_less_than_start_raises(self):
"""Test end less than start raises ValueError."""
with pytest.raises(ValueError, match="greater than or equal"):
validate_episode_range(10, 5)
def test_range_too_large_raises(self):
"""Test range over 1000 raises ValueError."""
with pytest.raises(ValueError, match="too large"):
validate_episode_range(1, 1002)
class TestValidateDownloadQuality:
"""Tests for validate_download_quality function."""
@pytest.mark.parametrize("quality", [
"360p", "480p", "720p", "1080p", "best", "worst"
])
def test_valid_qualities(self, quality):
"""Test all valid quality values."""
assert validate_download_quality(quality) == quality
def test_invalid_quality_raises(self):
"""Test invalid quality raises ValueError."""
with pytest.raises(ValueError, match="Invalid quality"):
validate_download_quality("4k")
class TestValidateLanguage:
"""Tests for validate_language function."""
@pytest.mark.parametrize("language", [
"ger-sub", "ger-dub", "eng-sub", "eng-dub", "jpn"
])
def test_valid_languages(self, language):
"""Test all valid language values."""
assert validate_language(language) == language
def test_invalid_language_raises(self):
"""Test invalid language raises ValueError."""
with pytest.raises(ValueError, match="Invalid language"):
validate_language("spanish")
class TestValidateDownloadPriority:
"""Tests for validate_download_priority function."""
def test_valid_priority_min(self):
"""Test minimum priority is valid."""
assert validate_download_priority(0) == 0
def test_valid_priority_max(self):
"""Test maximum priority is valid."""
assert validate_download_priority(10) == 10
def test_negative_priority_raises(self):
"""Test negative priority raises ValueError."""
with pytest.raises(ValueError, match="between 0 and 10"):
validate_download_priority(-1)
def test_priority_too_high_raises(self):
"""Test priority over 10 raises ValueError."""
with pytest.raises(ValueError, match="between 0 and 10"):
validate_download_priority(11)
class TestValidateAnimeUrl:
"""Tests for validate_anime_url function."""
def test_valid_aniworld_url(self):
"""Test valid aniworld.to URL."""
url = "https://aniworld.to/anime/stream/attack-on-titan"
assert validate_anime_url(url) == url
def test_valid_s_to_url(self):
"""Test valid s.to URL."""
url = "https://s.to/serie/stream/naruto"
assert validate_anime_url(url) == url
def test_empty_url_raises(self):
"""Test empty URL raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_anime_url("")
def test_invalid_domain_raises(self):
"""Test invalid domain raises ValueError."""
with pytest.raises(ValueError, match="aniworld.to or s.to"):
validate_anime_url("https://example.com/anime")
class TestValidateBackupName:
"""Tests for validate_backup_name function."""
def test_valid_backup_name(self):
"""Test valid backup name."""
assert validate_backup_name("backup-2024.json") == "backup-2024.json"
def test_empty_raises(self):
"""Test empty name raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_backup_name("")
def test_invalid_chars_raises(self):
"""Test invalid characters raise ValueError."""
with pytest.raises(ValueError, match="only contain"):
validate_backup_name("backup name.json")
def test_no_json_extension_raises(self):
"""Test missing .json raises ValueError."""
with pytest.raises(ValueError, match="end with .json"):
validate_backup_name("backup.txt")
class TestValidateConfigData:
"""Tests for validate_config_data function."""
def test_valid_config(self):
"""Test valid config data."""
data = {
"download_directory": "/downloads",
"concurrent_downloads": 3
}
assert validate_config_data(data) == data
def test_missing_keys_raises(self):
"""Test missing required keys raises ValueError."""
with pytest.raises(ValueError, match="missing required keys"):
validate_config_data({"download_directory": "/downloads"})
def test_invalid_concurrent_downloads_raises(self):
"""Test invalid concurrent_downloads raises ValueError."""
with pytest.raises(ValueError, match="between 1 and 10"):
validate_config_data({
"download_directory": "/downloads",
"concurrent_downloads": 15
})
class TestSanitizeFilename:
"""Tests for sanitize_filename function."""
def test_valid_filename(self):
"""Test valid filename unchanged."""
assert sanitize_filename("episode-01.mp4") == "episode-01.mp4"
def test_removes_invalid_chars(self):
"""Test invalid characters are replaced."""
result = sanitize_filename("file<>name.mp4")
assert "<" not in result
assert ">" not in result
def test_strips_dots_spaces(self):
"""Test leading/trailing dots and spaces removed."""
assert sanitize_filename(" .filename. ") == "filename"
def test_empty_becomes_unnamed(self):
"""Test empty filename becomes 'unnamed'."""
assert sanitize_filename("") == "unnamed"
class TestValidateJwtToken:
"""Tests for validate_jwt_token function."""
def test_valid_token_format(self):
"""Test valid JWT token format."""
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" # noqa: E501
assert validate_jwt_token(token) == token
def test_empty_raises(self):
"""Test empty token raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_jwt_token("")
def test_invalid_format_raises(self):
"""Test invalid format raises ValueError."""
with pytest.raises(ValueError, match="Invalid JWT"):
validate_jwt_token("not-a-jwt-token")
class TestValidateIpAddress:
"""Tests for validate_ip_address function."""
def test_valid_ipv4(self):
"""Test valid IPv4 address."""
assert validate_ip_address("192.168.1.1") == "192.168.1.1"
def test_valid_ipv4_localhost(self):
"""Test localhost IPv4."""
assert validate_ip_address("127.0.0.1") == "127.0.0.1"
def test_empty_raises(self):
"""Test empty IP raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_ip_address("")
def test_invalid_ip_raises(self):
"""Test invalid IP raises ValueError."""
with pytest.raises(ValueError, match="Invalid IP"):
validate_ip_address("not-an-ip")
class TestValidateWebsocketMessage:
"""Tests for validate_websocket_message function."""
def test_valid_message(self):
"""Test valid WebSocket message."""
msg = {"type": "download_progress", "data": {}}
assert validate_websocket_message(msg) == msg
def test_missing_type_raises(self):
"""Test missing type raises ValueError."""
with pytest.raises(ValueError, match="missing required keys"):
validate_websocket_message({"data": {}})
def test_invalid_type_raises(self):
"""Test invalid type raises ValueError."""
with pytest.raises(ValueError, match="Invalid message type"):
validate_websocket_message({"type": "invalid_type"})
class TestValidatorMixin:
"""Tests for ValidatorMixin class methods."""
def test_validate_password_strength_valid(self):
"""Test valid password passes."""
password = "SecurePass123!"
assert ValidatorMixin.validate_password_strength(password) == password
def test_validate_password_too_short_raises(self):
"""Test short password raises ValueError."""
with pytest.raises(ValueError, match="8 characters"):
ValidatorMixin.validate_password_strength("Short1!")
def test_validate_password_no_uppercase_raises(self):
"""Test no uppercase raises ValueError."""
with pytest.raises(ValueError, match="uppercase"):
ValidatorMixin.validate_password_strength("lowercase123!")
def test_validate_password_no_lowercase_raises(self):
"""Test no lowercase raises ValueError."""
with pytest.raises(ValueError, match="lowercase"):
ValidatorMixin.validate_password_strength("UPPERCASE123!")
def test_validate_password_no_digit_raises(self):
"""Test no digit raises ValueError."""
with pytest.raises(ValueError, match="digit"):
ValidatorMixin.validate_password_strength("NoDigitsHere!")
def test_validate_password_no_special_raises(self):
"""Test no special char raises ValueError."""
with pytest.raises(ValueError, match="special character"):
ValidatorMixin.validate_password_strength("NoSpecial123")
def test_validate_url_valid(self):
"""Test valid URL."""
url = "https://example.com/path"
assert ValidatorMixin.validate_url(url) == url
def test_validate_url_invalid_raises(self):
"""Test invalid URL raises ValueError."""
with pytest.raises(ValueError, match="Invalid URL"):
ValidatorMixin.validate_url("not-a-url")
def test_validate_port_valid(self):
"""Test valid port."""
assert ValidatorMixin.validate_port(8080) == 8080
def test_validate_port_invalid_raises(self):
"""Test invalid port raises ValueError."""
with pytest.raises(ValueError, match="between 1 and 65535"):
ValidatorMixin.validate_port(70000)
def test_validate_positive_integer_valid(self):
"""Test valid positive integer."""
assert ValidatorMixin.validate_positive_integer(5) == 5
def test_validate_positive_integer_zero_raises(self):
"""Test zero raises ValueError."""
with pytest.raises(ValueError, match="must be positive"):
ValidatorMixin.validate_positive_integer(0)
def test_validate_non_negative_integer_valid(self):
"""Test valid non-negative integer."""
assert ValidatorMixin.validate_non_negative_integer(0) == 0
def test_validate_non_negative_integer_negative_raises(self):
"""Test negative raises ValueError."""
with pytest.raises(ValueError, match="cannot be negative"):
ValidatorMixin.validate_non_negative_integer(-1)
def test_validate_string_length_valid(self):
"""Test valid string length."""
result = ValidatorMixin.validate_string_length("test", 1, 10)
assert result == "test"
def test_validate_string_length_too_short_raises(self):
"""Test too short raises ValueError."""
with pytest.raises(ValueError, match="at least"):
ValidatorMixin.validate_string_length("ab", min_length=5)
def test_validate_string_length_too_long_raises(self):
"""Test too long raises ValueError."""
with pytest.raises(ValueError, match="at most"):
ValidatorMixin.validate_string_length("abcdefgh", max_length=5)
def test_validate_choice_valid(self):
"""Test valid choice."""
result = ValidatorMixin.validate_choice("a", ["a", "b", "c"])
assert result == "a"
def test_validate_choice_invalid_raises(self):
"""Test invalid choice raises ValueError."""
with pytest.raises(ValueError, match="must be one of"):
ValidatorMixin.validate_choice("d", ["a", "b", "c"])
def test_validate_dict_keys_valid(self):
"""Test valid dict with required keys."""
data = {"a": 1, "b": 2}
result = ValidatorMixin.validate_dict_keys(data, ["a", "b"])
assert result == data
def test_validate_dict_keys_missing_raises(self):
"""Test missing keys raises ValueError."""
with pytest.raises(ValueError, match="missing required keys"):
ValidatorMixin.validate_dict_keys({"a": 1}, ["a", "b"])