- Add validate_series_key() function that validates URL-safe, lowercase, hyphen-separated series keys (e.g., 'attack-on-titan') - Add validate_series_key_or_folder() for backward compatibility during transition from folder-based to key-based identification - Create comprehensive test suite with 99 test cases for all validators - Update infrastructure.md with validation utilities documentation - Mark Task 4.6 as complete in instructions.md Test: conda run -n AniWorld python -m pytest tests/unit/test_validators.py -v All 99 validator tests pass, 718 total unit tests pass
534 lines
20 KiB
Python
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,
|
|
validate_series_key,
|
|
validate_series_key_or_folder,
|
|
validate_series_name,
|
|
validate_episode_range,
|
|
validate_download_quality,
|
|
validate_language,
|
|
validate_download_priority,
|
|
validate_anime_url,
|
|
validate_backup_name,
|
|
validate_config_data,
|
|
sanitize_filename,
|
|
validate_jwt_token,
|
|
validate_ip_address,
|
|
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"])
|