""" 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"])