- Add sanitize_folder_name utility for filesystem-safe folder names - Add sanitized_folder property to Serie entity - Update SerieList.add() to use sanitized display names for folders - Add scan_single_series() method for targeted episode scanning - Enhance add_series endpoint: DB save -> folder create -> targeted scan - Update response to include missing_episodes and total_missing - Add comprehensive unit tests for new functionality - Update API tests with proper mock support
416 lines
14 KiB
Python
416 lines
14 KiB
Python
"""
|
|
Unit tests for Serie class to verify key validation and identifier usage.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
from src.core.entities.series import Serie
|
|
|
|
|
|
class TestSerieValidation:
|
|
"""Test Serie class validation logic."""
|
|
|
|
def test_serie_creation_with_valid_key(self):
|
|
"""Test creating Serie with valid key."""
|
|
serie = Serie(
|
|
key="attack-on-titan",
|
|
name="Attack on Titan",
|
|
site="https://aniworld.to/anime/stream/attack-on-titan",
|
|
folder="Attack on Titan (2013)",
|
|
episodeDict={1: [1, 2, 3], 2: [1, 2]}
|
|
)
|
|
|
|
assert serie.key == "attack-on-titan"
|
|
assert serie.name == "Attack on Titan"
|
|
assert serie.site == "https://aniworld.to/anime/stream/attack-on-titan"
|
|
assert serie.folder == "Attack on Titan (2013)"
|
|
assert serie.episodeDict == {1: [1, 2, 3], 2: [1, 2]}
|
|
|
|
def test_serie_creation_with_empty_key_raises_error(self):
|
|
"""Test that creating Serie with empty key raises ValueError."""
|
|
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
|
Serie(
|
|
key="",
|
|
name="Test Series",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1]}
|
|
)
|
|
|
|
def test_serie_creation_with_whitespace_key_raises_error(self):
|
|
"""Test that creating Serie with whitespace-only key raises error."""
|
|
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
|
Serie(
|
|
key=" ",
|
|
name="Test Series",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1]}
|
|
)
|
|
|
|
def test_serie_key_is_stripped(self):
|
|
"""Test that Serie key is stripped of whitespace."""
|
|
serie = Serie(
|
|
key=" attack-on-titan ",
|
|
name="Attack on Titan",
|
|
site="https://example.com",
|
|
folder="Attack on Titan (2013)",
|
|
episodeDict={1: [1]}
|
|
)
|
|
|
|
assert serie.key == "attack-on-titan"
|
|
|
|
def test_serie_key_setter_with_valid_value(self):
|
|
"""Test setting key property with valid value."""
|
|
serie = Serie(
|
|
key="initial-key",
|
|
name="Test",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1]}
|
|
)
|
|
|
|
serie.key = "new-key"
|
|
assert serie.key == "new-key"
|
|
|
|
def test_serie_key_setter_with_empty_value_raises_error(self):
|
|
"""Test that setting key to empty string raises ValueError."""
|
|
serie = Serie(
|
|
key="initial-key",
|
|
name="Test",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1]}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
|
serie.key = ""
|
|
|
|
def test_serie_key_setter_with_whitespace_raises_error(self):
|
|
"""Test that setting key to whitespace raises ValueError."""
|
|
serie = Serie(
|
|
key="initial-key",
|
|
name="Test",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1]}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
|
serie.key = " "
|
|
|
|
def test_serie_key_setter_strips_whitespace(self):
|
|
"""Test that key setter strips whitespace."""
|
|
serie = Serie(
|
|
key="initial-key",
|
|
name="Test",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1]}
|
|
)
|
|
|
|
serie.key = " new-key "
|
|
assert serie.key == "new-key"
|
|
|
|
|
|
class TestSerieProperties:
|
|
"""Test Serie class properties and methods."""
|
|
|
|
def test_serie_str_representation(self):
|
|
"""Test string representation of Serie."""
|
|
serie = Serie(
|
|
key="test-key",
|
|
name="Test Series",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1, 2]}
|
|
)
|
|
|
|
str_repr = str(serie)
|
|
assert "key='test-key'" in str_repr
|
|
assert "name='Test Series'" in str_repr
|
|
assert "folder='Test Folder'" in str_repr
|
|
|
|
def test_serie_to_dict(self):
|
|
"""Test conversion of Serie to dictionary."""
|
|
serie = Serie(
|
|
key="test-key",
|
|
name="Test Series",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1, 2], 2: [1, 2, 3]}
|
|
)
|
|
|
|
data = serie.to_dict()
|
|
|
|
assert data["key"] == "test-key"
|
|
assert data["name"] == "Test Series"
|
|
assert data["site"] == "https://example.com"
|
|
assert data["folder"] == "Test Folder"
|
|
assert "1" in data["episodeDict"]
|
|
assert data["episodeDict"]["1"] == [1, 2]
|
|
|
|
def test_serie_from_dict(self):
|
|
"""Test creating Serie from dictionary."""
|
|
data = {
|
|
"key": "test-key",
|
|
"name": "Test Series",
|
|
"site": "https://example.com",
|
|
"folder": "Test Folder",
|
|
"episodeDict": {"1": [1, 2], "2": [1, 2, 3]}
|
|
}
|
|
|
|
serie = Serie.from_dict(data)
|
|
|
|
assert serie.key == "test-key"
|
|
assert serie.name == "Test Series"
|
|
assert serie.folder == "Test Folder"
|
|
assert serie.episodeDict == {1: [1, 2], 2: [1, 2, 3]}
|
|
|
|
def test_serie_save_and_load_from_file(self):
|
|
"""Test saving and loading Serie from file."""
|
|
import warnings
|
|
|
|
serie = Serie(
|
|
key="test-key",
|
|
name="Test Series",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1, 2, 3]}
|
|
)
|
|
|
|
# Create temporary file
|
|
with tempfile.NamedTemporaryFile(
|
|
mode='w',
|
|
delete=False,
|
|
suffix='.json'
|
|
) as f:
|
|
temp_filename = f.name
|
|
|
|
try:
|
|
# Suppress deprecation warnings for this test
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
|
|
# Save to file
|
|
serie.save_to_file(temp_filename)
|
|
|
|
# Load from file
|
|
loaded_serie = Serie.load_from_file(temp_filename)
|
|
|
|
# Verify all properties match
|
|
assert loaded_serie.key == serie.key
|
|
assert loaded_serie.name == serie.name
|
|
assert loaded_serie.site == serie.site
|
|
assert loaded_serie.folder == serie.folder
|
|
assert loaded_serie.episodeDict == serie.episodeDict
|
|
finally:
|
|
# Cleanup
|
|
if os.path.exists(temp_filename):
|
|
os.remove(temp_filename)
|
|
|
|
def test_serie_folder_is_mutable(self):
|
|
"""Test that folder property can be changed (it's metadata only)."""
|
|
serie = Serie(
|
|
key="test-key",
|
|
name="Test",
|
|
site="https://example.com",
|
|
folder="Old Folder",
|
|
episodeDict={1: [1]}
|
|
)
|
|
|
|
serie.folder = "New Folder"
|
|
assert serie.folder == "New Folder"
|
|
# Key should remain unchanged
|
|
assert serie.key == "test-key"
|
|
|
|
|
|
class TestSerieDocumentation:
|
|
"""Test that Serie class has proper documentation."""
|
|
|
|
def test_serie_class_has_docstring(self):
|
|
"""Test that Serie class has a docstring."""
|
|
assert Serie.__doc__ is not None
|
|
assert "unique identifier" in Serie.__doc__.lower()
|
|
|
|
def test_key_property_has_docstring(self):
|
|
"""Test that key property has descriptive docstring."""
|
|
assert Serie.key.fget.__doc__ is not None
|
|
assert "unique" in Serie.key.fget.__doc__.lower()
|
|
assert "identifier" in Serie.key.fget.__doc__.lower()
|
|
|
|
def test_folder_property_has_docstring(self):
|
|
"""Test that folder property documents it's metadata only."""
|
|
assert Serie.folder.fget.__doc__ is not None
|
|
assert "metadata" in Serie.folder.fget.__doc__.lower()
|
|
assert "not used for lookups" in Serie.folder.fget.__doc__.lower()
|
|
|
|
|
|
class TestSerieDeprecationWarnings:
|
|
"""Test deprecation warnings for file-based methods."""
|
|
|
|
def test_save_to_file_raises_deprecation_warning(self):
|
|
"""Test save_to_file() raises deprecation warning."""
|
|
import warnings
|
|
|
|
serie = Serie(
|
|
key="test-key",
|
|
name="Test Series",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1, 2, 3]}
|
|
)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode='w', suffix='.json', delete=False
|
|
) as temp_file:
|
|
temp_filename = temp_file.name
|
|
|
|
try:
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
serie.save_to_file(temp_filename)
|
|
|
|
# Check deprecation warning was raised
|
|
assert len(w) == 1
|
|
assert issubclass(w[0].category, DeprecationWarning)
|
|
assert "deprecated" in str(w[0].message).lower()
|
|
assert "save_to_file" in str(w[0].message)
|
|
finally:
|
|
if os.path.exists(temp_filename):
|
|
os.remove(temp_filename)
|
|
|
|
def test_load_from_file_raises_deprecation_warning(self):
|
|
"""Test load_from_file() raises deprecation warning."""
|
|
import warnings
|
|
|
|
serie = Serie(
|
|
key="test-key",
|
|
name="Test Series",
|
|
site="https://example.com",
|
|
folder="Test Folder",
|
|
episodeDict={1: [1, 2, 3]}
|
|
)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode='w', suffix='.json', delete=False
|
|
) as temp_file:
|
|
temp_filename = temp_file.name
|
|
|
|
try:
|
|
# Save first (suppress warning for this)
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore")
|
|
serie.save_to_file(temp_filename)
|
|
|
|
# Now test loading
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
Serie.load_from_file(temp_filename)
|
|
|
|
# Check deprecation warning was raised
|
|
assert len(w) == 1
|
|
assert issubclass(w[0].category, DeprecationWarning)
|
|
assert "deprecated" in str(w[0].message).lower()
|
|
assert "load_from_file" in str(w[0].message)
|
|
finally:
|
|
if os.path.exists(temp_filename):
|
|
os.remove(temp_filename)
|
|
|
|
|
|
class TestSerieSanitizedFolder:
|
|
"""Test Serie.sanitized_folder property."""
|
|
|
|
def test_sanitized_folder_from_name(self):
|
|
"""Test that sanitized_folder uses the name property."""
|
|
serie = Serie(
|
|
key="attack-on-titan",
|
|
name="Attack on Titan: Final Season",
|
|
site="aniworld.to",
|
|
folder="old-folder",
|
|
episodeDict={}
|
|
)
|
|
|
|
result = serie.sanitized_folder
|
|
assert ":" not in result
|
|
assert "Attack on Titan" in result
|
|
|
|
def test_sanitized_folder_removes_special_chars(self):
|
|
"""Test that special characters are removed."""
|
|
serie = Serie(
|
|
key="re-zero",
|
|
name="Re:Zero - Starting Life in Another World?",
|
|
site="aniworld.to",
|
|
folder="old-folder",
|
|
episodeDict={}
|
|
)
|
|
|
|
result = serie.sanitized_folder
|
|
assert ":" not in result
|
|
assert "?" not in result
|
|
|
|
def test_sanitized_folder_fallback_to_folder(self):
|
|
"""Test fallback to folder when name is empty."""
|
|
serie = Serie(
|
|
key="test-key",
|
|
name="",
|
|
site="aniworld.to",
|
|
folder="Valid Folder Name",
|
|
episodeDict={}
|
|
)
|
|
|
|
result = serie.sanitized_folder
|
|
assert result == "Valid Folder Name"
|
|
|
|
def test_sanitized_folder_fallback_to_key(self):
|
|
"""Test fallback to key when name and folder can't be sanitized."""
|
|
serie = Serie(
|
|
key="valid-key",
|
|
name="",
|
|
site="aniworld.to",
|
|
folder="",
|
|
episodeDict={}
|
|
)
|
|
|
|
result = serie.sanitized_folder
|
|
assert result == "valid-key"
|
|
|
|
def test_sanitized_folder_preserves_unicode(self):
|
|
"""Test that Unicode characters are preserved."""
|
|
serie = Serie(
|
|
key="japanese-anime",
|
|
name="進撃の巨人",
|
|
site="aniworld.to",
|
|
folder="old-folder",
|
|
episodeDict={}
|
|
)
|
|
|
|
result = serie.sanitized_folder
|
|
assert "進撃の巨人" in result
|
|
|
|
def test_sanitized_folder_with_various_anime_titles(self):
|
|
"""Test sanitized_folder with real anime titles."""
|
|
test_cases = [
|
|
("fate-stay-night", "Fate/Stay Night: UBW"),
|
|
("86-eighty-six", "86: Eighty-Six"),
|
|
("steins-gate", "Steins;Gate"),
|
|
]
|
|
|
|
for key, name in test_cases:
|
|
serie = Serie(
|
|
key=key,
|
|
name=name,
|
|
site="aniworld.to",
|
|
folder="old-folder",
|
|
episodeDict={}
|
|
)
|
|
result = serie.sanitized_folder
|
|
# Verify invalid filesystem characters are removed
|
|
# Note: semicolon is valid on Linux but we test common invalid chars
|
|
assert ":" not in result
|
|
assert "/" not in result
|