feat: Add validate_series_key() validator for key-based identification (Task 4.6)
- 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
This commit is contained in:
parent
08c7264d7a
commit
c00224467f
@ -156,6 +156,32 @@ Main engine for anime series management with async support, progress callbacks,
|
||||
| AuthService | JWT authentication, rate limiting |
|
||||
| ConfigService | Configuration persistence with backups |
|
||||
|
||||
## Validation Utilities (`src/server/utils/validators.py`)
|
||||
|
||||
Provides data validation functions for ensuring data integrity across the application.
|
||||
|
||||
### Series Key Validation
|
||||
|
||||
- **`validate_series_key(key)`**: Validates key format (URL-safe, lowercase, hyphens only)
|
||||
- Valid: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`
|
||||
- Invalid: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
|
||||
- **`validate_series_key_or_folder(identifier, allow_folder=True)`**: Backward-compatible validation
|
||||
- Returns tuple `(identifier, is_key)` where `is_key` indicates if it's a valid key format
|
||||
- Set `allow_folder=False` to require strict key format
|
||||
|
||||
### Other Validators
|
||||
|
||||
| Function | Purpose |
|
||||
| --------------------------- | ------------------------------------------- |
|
||||
| `validate_series_name` | Series display name validation |
|
||||
| `validate_episode_range` | Episode range validation (1-1000) |
|
||||
| `validate_download_quality` | Quality setting (360p-1080p, best, worst) |
|
||||
| `validate_language` | Language codes (ger-sub, ger-dub, etc.) |
|
||||
| `validate_anime_url` | Aniworld.to/s.to URL validation |
|
||||
| `validate_backup_name` | Backup filename validation |
|
||||
| `validate_config_data` | Configuration data structure validation |
|
||||
| `sanitize_filename` | Sanitize filenames for safe filesystem use |
|
||||
|
||||
## Frontend
|
||||
|
||||
### Static Files
|
||||
|
||||
@ -170,35 +170,7 @@ For each task completed:
|
||||
|
||||
#### Task 4.5: Update Pydantic Models to Use Key ✅ (November 27, 2025)
|
||||
|
||||
---
|
||||
|
||||
#### Task 4.6: Update Validators to Use Key
|
||||
|
||||
**File:** `src/server/utils/validators.py`
|
||||
|
||||
**Objective:** Ensure validation functions validate `key` instead of `folder`.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Open `src/server/utils/validators.py`
|
||||
2. Review all validation functions
|
||||
3. Add `validate_series_key()` function if not exists
|
||||
4. Update any validators that check series identifiers
|
||||
5. Ensure validators accept `key` format (URL-safe, lowercase with hyphens)
|
||||
6. Add tests for key validation
|
||||
|
||||
**Success Criteria:**
|
||||
|
||||
- [ ] Validators validate `key` format
|
||||
- [ ] No validators use `folder` for identification
|
||||
- [ ] Validation functions well-documented
|
||||
- [ ] All validator tests pass
|
||||
|
||||
**Test Command:**
|
||||
|
||||
```bash
|
||||
conda run -n AniWorld python -m pytest tests/unit/ -k "validator" -v
|
||||
```
|
||||
#### Task 4.6: Update Validators to Use Key ✅ (November 28, 2025)
|
||||
|
||||
---
|
||||
|
||||
@ -769,7 +741,7 @@ conda run -n AniWorld python -m pytest tests/integration/test_identifier_consist
|
||||
- [x] Task 4.3: Update Queue API Endpoints ✅ **Completed November 27, 2025**
|
||||
- [x] Task 4.4: Update WebSocket API Endpoints ✅ **Completed November 27, 2025**
|
||||
- [x] Task 4.5: Update Pydantic Models ✅ **Completed November 27, 2025**
|
||||
- [ ] Task 4.6: Update Validators
|
||||
- [x] Task 4.6: Update Validators ✅ **Completed November 28, 2025**
|
||||
- [ ] Task 4.7: Update Template Helpers
|
||||
- [ ] Phase 5: Frontend
|
||||
- [ ] Task 5.1: Update Frontend JavaScript
|
||||
|
||||
@ -438,6 +438,107 @@ def validate_series_name(name: str) -> str:
|
||||
return name.strip()
|
||||
|
||||
|
||||
def validate_series_key(key: str) -> str:
|
||||
"""
|
||||
Validate series key format.
|
||||
|
||||
Series keys are unique, provider-assigned, URL-safe identifiers.
|
||||
They should be lowercase, use hyphens for word separation, and contain
|
||||
only alphanumeric characters and hyphens.
|
||||
|
||||
Valid examples:
|
||||
- "attack-on-titan"
|
||||
- "one-piece"
|
||||
- "naruto"
|
||||
|
||||
Invalid examples:
|
||||
- "Attack On Titan" (uppercase, spaces)
|
||||
- "attack_on_titan" (underscores)
|
||||
- "attack on titan" (spaces)
|
||||
- "" (empty)
|
||||
|
||||
Args:
|
||||
key: Series key to validate
|
||||
|
||||
Returns:
|
||||
Validated key (trimmed)
|
||||
|
||||
Raises:
|
||||
ValueError: If key is invalid
|
||||
"""
|
||||
if not key or not isinstance(key, str):
|
||||
raise ValueError("Series key must be a non-empty string")
|
||||
|
||||
key = key.strip()
|
||||
|
||||
if not key:
|
||||
raise ValueError("Series key cannot be empty")
|
||||
|
||||
if len(key) > 255:
|
||||
raise ValueError("Series key must be 255 characters or less")
|
||||
|
||||
# Key must be lowercase, alphanumeric with hyphens only
|
||||
# Pattern: starts with letter/number, can contain letters, numbers, hyphens
|
||||
# Cannot start or end with hyphen, no consecutive hyphens
|
||||
if not re.match(r'^[a-z0-9]+(?:-[a-z0-9]+)*$', key):
|
||||
raise ValueError(
|
||||
"Series key must be lowercase, URL-safe, and use hyphens "
|
||||
"for word separation (e.g., 'attack-on-titan'). "
|
||||
"No spaces, underscores, or uppercase letters allowed."
|
||||
)
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def validate_series_key_or_folder(
|
||||
identifier: str, allow_folder: bool = True
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
Validate an identifier that could be either a series key or folder.
|
||||
|
||||
This function provides backward compatibility during the transition
|
||||
from folder-based to key-based identification.
|
||||
|
||||
Args:
|
||||
identifier: The identifier to validate (key or folder)
|
||||
allow_folder: Whether to allow folder-style identifiers (default: True)
|
||||
|
||||
Returns:
|
||||
Tuple of (validated_identifier, is_key) where is_key indicates
|
||||
whether the identifier is a valid key format.
|
||||
|
||||
Raises:
|
||||
ValueError: If identifier is empty or invalid
|
||||
"""
|
||||
if not identifier or not isinstance(identifier, str):
|
||||
raise ValueError("Identifier must be a non-empty string")
|
||||
|
||||
identifier = identifier.strip()
|
||||
|
||||
if not identifier:
|
||||
raise ValueError("Identifier cannot be empty")
|
||||
|
||||
# Try to validate as key first
|
||||
try:
|
||||
validate_series_key(identifier)
|
||||
return identifier, True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# If not a valid key, check if folder format is allowed
|
||||
if not allow_folder:
|
||||
raise ValueError(
|
||||
f"Invalid series key format: '{identifier}'. "
|
||||
"Keys must be lowercase with hyphens (e.g., 'attack-on-titan')."
|
||||
)
|
||||
|
||||
# Validate as folder (more permissive)
|
||||
if len(identifier) > 1000:
|
||||
raise ValueError("Identifier too long (max 1000 characters)")
|
||||
|
||||
return identifier, False
|
||||
|
||||
|
||||
def validate_backup_name(name: str) -> str:
|
||||
"""
|
||||
Validate backup file name.
|
||||
|
||||
533
tests/unit/test_validators.py
Normal file
533
tests/unit/test_validators.py
Normal file
@ -0,0 +1,533 @@
|
||||
"""
|
||||
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"])
|
||||
Loading…
x
Reference in New Issue
Block a user