feat: Enhanced anime add flow with sanitized folders and targeted scan
- 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
This commit is contained in:
@@ -42,11 +42,17 @@ class FakeSeriesApp:
|
||||
def __init__(self):
|
||||
"""Initialize fake series app."""
|
||||
self.list = self # Changed from self.List to self.list
|
||||
self.scanner = FakeScanner() # Add fake scanner
|
||||
self.directory = "/tmp/fake_anime"
|
||||
self.keyDict = {} # Add keyDict for direct access
|
||||
self._items = [
|
||||
# Using realistic key values (URL-safe, lowercase, hyphenated)
|
||||
FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}),
|
||||
FakeSerie("complete-show-key", "Complete Show", "Complete Show (2022)", {}),
|
||||
]
|
||||
# Populate keyDict
|
||||
for item in self._items:
|
||||
self.keyDict[item.key] = item
|
||||
|
||||
def GetMissingEpisode(self):
|
||||
"""Return series with missing episodes."""
|
||||
@@ -60,11 +66,21 @@ class FakeSeriesApp:
|
||||
"""Trigger rescan with callback."""
|
||||
callback()
|
||||
|
||||
def add(self, serie):
|
||||
"""Add a serie to the list."""
|
||||
def add(self, serie, use_sanitized_folder=True):
|
||||
"""Add a serie to the list.
|
||||
|
||||
Args:
|
||||
serie: The Serie instance to add
|
||||
use_sanitized_folder: Whether to use sanitized folder name
|
||||
|
||||
Returns:
|
||||
str: The folder path (fake path for testing)
|
||||
"""
|
||||
# Check if already exists
|
||||
if not any(s.key == serie.key for s in self._items):
|
||||
self._items.append(serie)
|
||||
self.keyDict[serie.key] = serie
|
||||
return f"/tmp/fake_anime/{serie.folder}"
|
||||
|
||||
async def search(self, query):
|
||||
"""Search for series (async)."""
|
||||
@@ -85,6 +101,14 @@ class FakeSeriesApp:
|
||||
pass
|
||||
|
||||
|
||||
class FakeScanner:
|
||||
"""Mock SerieScanner for testing."""
|
||||
|
||||
def scan_single_series(self, key, folder):
|
||||
"""Mock scan that returns some fake missing episodes."""
|
||||
return {1: [1, 2, 3], 2: [1, 2]}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth_state():
|
||||
"""Reset auth service state before each test."""
|
||||
@@ -273,3 +297,122 @@ async def test_add_series_endpoint_empty_link(authenticated_client):
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "link" in data["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_extracts_key_from_full_url(authenticated_client):
|
||||
"""Test that add_series extracts key from full URL."""
|
||||
response = await authenticated_client.post(
|
||||
"/api/anime/add",
|
||||
json={
|
||||
"link": "https://aniworld.to/anime/stream/attack-on-titan",
|
||||
"name": "Attack on Titan"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == "attack-on-titan"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_sanitizes_folder_name(authenticated_client):
|
||||
"""Test that add_series creates sanitized folder name."""
|
||||
response = await authenticated_client.post(
|
||||
"/api/anime/add",
|
||||
json={
|
||||
"link": "https://aniworld.to/anime/stream/rezero",
|
||||
"name": "Re:Zero - Starting Life in Another World?"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Folder should not contain invalid characters
|
||||
folder = data["folder"]
|
||||
assert ":" not in folder
|
||||
assert "?" not in folder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_returns_missing_episodes(authenticated_client):
|
||||
"""Test that add_series returns missing episodes info."""
|
||||
response = await authenticated_client.post(
|
||||
"/api/anime/add",
|
||||
json={
|
||||
"link": "https://aniworld.to/anime/stream/test-anime",
|
||||
"name": "Test Anime"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Response should contain missing episodes fields
|
||||
assert "missing_episodes" in data
|
||||
assert "total_missing" in data
|
||||
assert isinstance(data["missing_episodes"], dict)
|
||||
assert isinstance(data["total_missing"], int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_response_structure(authenticated_client):
|
||||
"""Test the full response structure of add_series."""
|
||||
response = await authenticated_client.post(
|
||||
"/api/anime/add",
|
||||
json={
|
||||
"link": "https://aniworld.to/anime/stream/new-anime",
|
||||
"name": "New Anime Series"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify all expected fields are present
|
||||
assert "status" in data
|
||||
assert "message" in data
|
||||
assert "key" in data
|
||||
assert "folder" in data
|
||||
assert "missing_episodes" in data
|
||||
assert "total_missing" in data
|
||||
|
||||
# Status should be success or exists
|
||||
assert data["status"] in ("success", "exists")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_special_characters_in_name(authenticated_client):
|
||||
"""Test adding series with various special characters in name."""
|
||||
test_cases = [
|
||||
("86: Eighty-Six", "86-eighty-six"),
|
||||
("Fate/Stay Night", "fate-stay-night"),
|
||||
("What If...?", "what-if"),
|
||||
("Steins;Gate", "steins-gate"),
|
||||
]
|
||||
|
||||
for name, key in test_cases:
|
||||
response = await authenticated_client.post(
|
||||
"/api/anime/add",
|
||||
json={
|
||||
"link": f"https://aniworld.to/anime/stream/{key}",
|
||||
"name": name
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Get just the folder name (last part of path)
|
||||
folder_path = data["folder"]
|
||||
# Handle both full paths and just folder names
|
||||
if "/" in folder_path:
|
||||
folder_name = folder_path.rstrip("/").split("/")[-1]
|
||||
else:
|
||||
folder_name = folder_path
|
||||
|
||||
# Folder name should not contain invalid filesystem characters
|
||||
invalid_chars = [':', '\\', '?', '*', '<', '>', '|', '"']
|
||||
for char in invalid_chars:
|
||||
assert char not in folder_name, f"Found '{char}' in folder name for {name}"
|
||||
|
||||
295
tests/unit/test_filesystem_utils.py
Normal file
295
tests/unit/test_filesystem_utils.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Unit tests for filesystem utilities.
|
||||
|
||||
Tests the sanitize_folder_name function and related filesystem utilities.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.utils.filesystem import (
|
||||
MAX_FOLDER_NAME_LENGTH,
|
||||
create_safe_folder,
|
||||
is_safe_path,
|
||||
sanitize_folder_name,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeFolderName:
|
||||
"""Test sanitize_folder_name function."""
|
||||
|
||||
def test_simple_name(self):
|
||||
"""Test sanitizing a simple name with no special characters."""
|
||||
assert sanitize_folder_name("Attack on Titan") == "Attack on Titan"
|
||||
|
||||
def test_name_with_colon(self):
|
||||
"""Test sanitizing name with colon."""
|
||||
result = sanitize_folder_name("Attack on Titan: Final Season")
|
||||
assert ":" not in result
|
||||
assert result == "Attack on Titan Final Season"
|
||||
|
||||
def test_name_with_question_mark(self):
|
||||
"""Test sanitizing name with question mark."""
|
||||
result = sanitize_folder_name("What If...?")
|
||||
assert "?" not in result
|
||||
# Trailing dots are stripped
|
||||
assert result == "What If"
|
||||
|
||||
def test_name_with_multiple_special_chars(self):
|
||||
"""Test sanitizing name with multiple special characters."""
|
||||
result = sanitize_folder_name('Test: "Episode" <1> | Part?')
|
||||
# All invalid chars should be removed
|
||||
assert ":" not in result
|
||||
assert '"' not in result
|
||||
assert "<" not in result
|
||||
assert ">" not in result
|
||||
assert "|" not in result
|
||||
assert "?" not in result
|
||||
|
||||
def test_name_with_forward_slash(self):
|
||||
"""Test sanitizing name with forward slash."""
|
||||
result = sanitize_folder_name("Attack/Titan")
|
||||
assert "/" not in result
|
||||
|
||||
def test_name_with_backslash(self):
|
||||
"""Test sanitizing name with backslash."""
|
||||
result = sanitize_folder_name("Attack\\Titan")
|
||||
assert "\\" not in result
|
||||
|
||||
def test_unicode_characters_preserved(self):
|
||||
"""Test that Unicode characters are preserved."""
|
||||
# Japanese title
|
||||
result = sanitize_folder_name("進撃の巨人")
|
||||
assert result == "進撃の巨人"
|
||||
|
||||
def test_mixed_unicode_and_special(self):
|
||||
"""Test mixed Unicode and special characters."""
|
||||
result = sanitize_folder_name("Re:ゼロ")
|
||||
assert ":" not in result
|
||||
assert "ゼロ" in result
|
||||
|
||||
def test_leading_dots_removed(self):
|
||||
"""Test that leading dots are removed."""
|
||||
result = sanitize_folder_name("...Hidden Folder")
|
||||
assert not result.startswith(".")
|
||||
|
||||
def test_trailing_dots_removed(self):
|
||||
"""Test that trailing dots are removed."""
|
||||
result = sanitize_folder_name("Folder Name...")
|
||||
assert not result.endswith(".")
|
||||
|
||||
def test_leading_spaces_removed(self):
|
||||
"""Test that leading spaces are removed."""
|
||||
result = sanitize_folder_name(" Attack on Titan")
|
||||
assert result == "Attack on Titan"
|
||||
|
||||
def test_trailing_spaces_removed(self):
|
||||
"""Test that trailing spaces are removed."""
|
||||
result = sanitize_folder_name("Attack on Titan ")
|
||||
assert result == "Attack on Titan"
|
||||
|
||||
def test_multiple_spaces_collapsed(self):
|
||||
"""Test that multiple consecutive spaces are collapsed."""
|
||||
result = sanitize_folder_name("Attack on Titan")
|
||||
assert result == "Attack on Titan"
|
||||
|
||||
def test_null_byte_removed(self):
|
||||
"""Test that null byte is removed."""
|
||||
result = sanitize_folder_name("Attack\x00Titan")
|
||||
assert "\x00" not in result
|
||||
|
||||
def test_newline_removed(self):
|
||||
"""Test that newline is removed."""
|
||||
result = sanitize_folder_name("Attack\nTitan")
|
||||
assert "\n" not in result
|
||||
|
||||
def test_tab_removed(self):
|
||||
"""Test that tab is removed."""
|
||||
result = sanitize_folder_name("Attack\tTitan")
|
||||
assert "\t" not in result
|
||||
|
||||
def test_none_raises_error(self):
|
||||
"""Test that None raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be None"):
|
||||
sanitize_folder_name(None)
|
||||
|
||||
def test_empty_string_raises_error(self):
|
||||
"""Test that empty string raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
sanitize_folder_name("")
|
||||
|
||||
def test_whitespace_only_raises_error(self):
|
||||
"""Test that whitespace-only string raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
sanitize_folder_name(" ")
|
||||
|
||||
def test_only_invalid_chars_raises_error(self):
|
||||
"""Test that string with only invalid characters raises ValueError."""
|
||||
with pytest.raises(ValueError, match="only invalid characters"):
|
||||
sanitize_folder_name("???:::***")
|
||||
|
||||
def test_max_length_truncation(self):
|
||||
"""Test that long names are truncated."""
|
||||
long_name = "A" * 300
|
||||
result = sanitize_folder_name(long_name)
|
||||
assert len(result) <= MAX_FOLDER_NAME_LENGTH
|
||||
|
||||
def test_max_length_custom(self):
|
||||
"""Test custom max length."""
|
||||
result = sanitize_folder_name("Attack on Titan", max_length=10)
|
||||
assert len(result) <= 10
|
||||
|
||||
def test_truncation_at_word_boundary(self):
|
||||
"""Test that truncation happens at word boundary when possible."""
|
||||
result = sanitize_folder_name(
|
||||
"The Very Long Anime Title That Needs Truncation",
|
||||
max_length=25
|
||||
)
|
||||
# Should truncate at word boundary
|
||||
assert len(result) <= 25
|
||||
assert not result.endswith(" ")
|
||||
|
||||
def test_custom_replacement_character(self):
|
||||
"""Test custom replacement character."""
|
||||
result = sanitize_folder_name("Test:Name", replacement="_")
|
||||
assert ":" not in result
|
||||
assert "Test_Name" == result
|
||||
|
||||
def test_asterisk_removed(self):
|
||||
"""Test that asterisk is removed."""
|
||||
result = sanitize_folder_name("Attack*Titan")
|
||||
assert "*" not in result
|
||||
|
||||
def test_pipe_removed(self):
|
||||
"""Test that pipe is removed."""
|
||||
result = sanitize_folder_name("Attack|Titan")
|
||||
assert "|" not in result
|
||||
|
||||
def test_real_anime_titles(self):
|
||||
"""Test real anime titles with special characters."""
|
||||
# Test that invalid filesystem characters are removed
|
||||
# Note: semicolon is NOT an invalid filesystem character
|
||||
test_cases = [
|
||||
("Re:Zero", ":"), # colon should be removed
|
||||
("86: Eighty-Six", ":"), # colon should be removed
|
||||
("Fate/Stay Night", "/"), # slash should be removed
|
||||
("Sword Art Online: Alicization", ":"), # colon should be removed
|
||||
("What If...?", "?"), # question mark should be removed
|
||||
]
|
||||
for input_name, forbidden_char in test_cases:
|
||||
result = sanitize_folder_name(input_name)
|
||||
assert forbidden_char not in result, f"'{forbidden_char}' should be removed from '{input_name}'"
|
||||
|
||||
|
||||
class TestIsSafePath:
|
||||
"""Test is_safe_path function."""
|
||||
|
||||
def test_valid_subpath(self):
|
||||
"""Test that valid subpath returns True."""
|
||||
assert is_safe_path("/anime", "/anime/Attack on Titan")
|
||||
|
||||
def test_exact_match(self):
|
||||
"""Test that exact match returns True."""
|
||||
assert is_safe_path("/anime", "/anime")
|
||||
|
||||
def test_path_traversal_rejected(self):
|
||||
"""Test that path traversal is rejected."""
|
||||
assert not is_safe_path("/anime", "/anime/../etc/passwd")
|
||||
|
||||
def test_parent_directory_rejected(self):
|
||||
"""Test that parent directory is rejected."""
|
||||
assert not is_safe_path("/anime/series", "/anime")
|
||||
|
||||
def test_sibling_directory_rejected(self):
|
||||
"""Test that sibling directory is rejected."""
|
||||
assert not is_safe_path("/anime", "/movies/film")
|
||||
|
||||
def test_nested_subpath(self):
|
||||
"""Test deeply nested valid subpath."""
|
||||
assert is_safe_path(
|
||||
"/anime",
|
||||
"/anime/Attack on Titan/Season 1/Episode 1"
|
||||
)
|
||||
|
||||
|
||||
class TestCreateSafeFolder:
|
||||
"""Test create_safe_folder function."""
|
||||
|
||||
def test_creates_folder_with_sanitized_name(self):
|
||||
"""Test that folder is created with sanitized name."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = create_safe_folder(tmpdir, "Attack: Titan?")
|
||||
assert os.path.isdir(path)
|
||||
assert ":" not in os.path.basename(path)
|
||||
assert "?" not in os.path.basename(path)
|
||||
|
||||
def test_returns_full_path(self):
|
||||
"""Test that full path is returned."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = create_safe_folder(tmpdir, "Test Folder")
|
||||
assert path.startswith(tmpdir)
|
||||
assert "Test Folder" in path
|
||||
|
||||
def test_exist_ok_true(self):
|
||||
"""Test that existing folder doesn't raise with exist_ok=True."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create first time
|
||||
path1 = create_safe_folder(tmpdir, "Test Folder")
|
||||
# Create second time - should not raise
|
||||
path2 = create_safe_folder(tmpdir, "Test Folder", exist_ok=True)
|
||||
assert path1 == path2
|
||||
|
||||
def test_rejects_path_traversal(self):
|
||||
"""Test that path traversal is rejected after sanitization."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# After sanitization, "../../../etc" becomes "etc" (dots removed)
|
||||
# So this test verifies the folder is created safely
|
||||
# The sanitization removes the path traversal attempt
|
||||
path = create_safe_folder(tmpdir, "../../../etc")
|
||||
# The folder should be created within tmpdir, not escape it
|
||||
assert is_safe_path(tmpdir, path)
|
||||
# Folder name should be "etc" after sanitization (dots stripped)
|
||||
assert os.path.basename(path) == "etc"
|
||||
|
||||
|
||||
class TestSanitizeFolderNameEdgeCases:
|
||||
"""Test edge cases for sanitize_folder_name."""
|
||||
|
||||
def test_control_characters_removed(self):
|
||||
"""Test that control characters are removed."""
|
||||
# ASCII control characters
|
||||
result = sanitize_folder_name("Test\x01\x02\x03Name")
|
||||
assert "\x01" not in result
|
||||
assert "\x02" not in result
|
||||
assert "\x03" not in result
|
||||
|
||||
def test_carriage_return_removed(self):
|
||||
"""Test that carriage return is removed."""
|
||||
result = sanitize_folder_name("Test\rName")
|
||||
assert "\r" not in result
|
||||
|
||||
def test_unicode_normalization(self):
|
||||
"""Test that Unicode is normalized."""
|
||||
# Composed vs decomposed forms
|
||||
result = sanitize_folder_name("café")
|
||||
# Should be normalized to NFC form
|
||||
assert result == "café"
|
||||
|
||||
def test_emoji_handling(self):
|
||||
"""Test handling of emoji characters."""
|
||||
result = sanitize_folder_name("Anime 🎬 Title")
|
||||
# Emoji should be preserved (valid Unicode)
|
||||
assert "🎬" in result or "Anime" in result
|
||||
|
||||
def test_single_character_name(self):
|
||||
"""Test single character name."""
|
||||
result = sanitize_folder_name("A")
|
||||
assert result == "A"
|
||||
|
||||
def test_numbers_preserved(self):
|
||||
"""Test that numbers are preserved."""
|
||||
result = sanitize_folder_name("86: Eighty-Six (2021)")
|
||||
assert "86" in result
|
||||
assert "2021" in result
|
||||
@@ -320,3 +320,96 @@ class TestSerieDeprecationWarnings:
|
||||
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
|
||||
|
||||
@@ -134,3 +134,186 @@ class TestSerieScannerScan:
|
||||
scanner.scan()
|
||||
|
||||
assert sample_serie.key in scanner.keyDict
|
||||
|
||||
|
||||
class TestSerieScannerSingleSeries:
|
||||
"""Test scan_single_series method for targeted scanning."""
|
||||
|
||||
def test_scan_single_series_basic(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Test basic scan_single_series functionality."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
# Mock the missing episodes calculation
|
||||
with patch.object(
|
||||
scanner,
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
return_value=({1: [5, 6, 7], 2: [1, 2]}, "aniworld.to")
|
||||
):
|
||||
result = scanner.scan_single_series(
|
||||
key="attack-on-titan",
|
||||
folder="Attack on Titan (2013)"
|
||||
)
|
||||
|
||||
# Verify result structure
|
||||
assert isinstance(result, dict)
|
||||
assert 1 in result
|
||||
assert 2 in result
|
||||
assert result[1] == [5, 6, 7]
|
||||
assert result[2] == [1, 2]
|
||||
|
||||
def test_scan_single_series_updates_keydict(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Test that scan_single_series updates keyDict."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
with patch.object(
|
||||
scanner,
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
return_value=({1: [1, 2, 3]}, "aniworld.to")
|
||||
):
|
||||
scanner.scan_single_series(
|
||||
key="test-anime",
|
||||
folder="Test Anime"
|
||||
)
|
||||
|
||||
# Verify keyDict was updated
|
||||
assert "test-anime" in scanner.keyDict
|
||||
assert scanner.keyDict["test-anime"].episodeDict == {1: [1, 2, 3]}
|
||||
|
||||
def test_scan_single_series_existing_entry(
|
||||
self, temp_directory, mock_loader, sample_serie
|
||||
):
|
||||
"""Test scan_single_series updates existing entry in keyDict."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
# Pre-populate keyDict
|
||||
scanner.keyDict[sample_serie.key] = sample_serie
|
||||
old_episode_dict = sample_serie.episodeDict.copy()
|
||||
|
||||
with patch.object(
|
||||
scanner,
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
return_value=({1: [10, 11, 12]}, "aniworld.to")
|
||||
):
|
||||
scanner.scan_single_series(
|
||||
key=sample_serie.key,
|
||||
folder=sample_serie.folder
|
||||
)
|
||||
|
||||
# Verify existing entry was updated
|
||||
assert scanner.keyDict[sample_serie.key].episodeDict != old_episode_dict
|
||||
assert scanner.keyDict[sample_serie.key].episodeDict == {1: [10, 11, 12]}
|
||||
|
||||
def test_scan_single_series_empty_key_raises_error(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Test that empty key raises ValueError."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
with pytest.raises(ValueError, match="key cannot be empty"):
|
||||
scanner.scan_single_series(key="", folder="Test Folder")
|
||||
|
||||
def test_scan_single_series_empty_folder_raises_error(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Test that empty folder raises ValueError."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
with pytest.raises(ValueError, match="folder cannot be empty"):
|
||||
scanner.scan_single_series(key="test-key", folder="")
|
||||
|
||||
def test_scan_single_series_nonexistent_folder(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Test scanning a series with non-existent folder."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
# Mock to return some episodes (as if from provider)
|
||||
with patch.object(
|
||||
scanner,
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
return_value=({1: [1, 2, 3, 4, 5]}, "aniworld.to")
|
||||
):
|
||||
result = scanner.scan_single_series(
|
||||
key="new-anime",
|
||||
folder="NonExistent Folder"
|
||||
)
|
||||
|
||||
# Should still return missing episodes from provider
|
||||
assert result == {1: [1, 2, 3, 4, 5]}
|
||||
|
||||
def test_scan_single_series_error_handling(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Test that errors during scan return empty dict."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
with patch.object(
|
||||
scanner,
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
side_effect=Exception("Provider error")
|
||||
):
|
||||
result = scanner.scan_single_series(
|
||||
key="test-anime",
|
||||
folder="Test Folder"
|
||||
)
|
||||
|
||||
# Should return empty dict on error
|
||||
assert result == {}
|
||||
|
||||
def test_scan_single_series_no_missing_episodes(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Test scan when no episodes are missing."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
with patch.object(
|
||||
scanner,
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
return_value=({}, "aniworld.to")
|
||||
):
|
||||
result = scanner.scan_single_series(
|
||||
key="complete-anime",
|
||||
folder="Complete Anime"
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
assert "complete-anime" in scanner.keyDict
|
||||
assert scanner.keyDict["complete-anime"].episodeDict == {}
|
||||
|
||||
def test_scan_single_series_with_existing_files(
|
||||
self, temp_directory, mock_loader
|
||||
):
|
||||
"""Test scan with existing MP4 files in folder."""
|
||||
# Create folder with some files
|
||||
anime_folder = os.path.join(temp_directory, "Test Anime")
|
||||
os.makedirs(anime_folder, exist_ok=True)
|
||||
season_folder = os.path.join(anime_folder, "Season 1")
|
||||
os.makedirs(season_folder, exist_ok=True)
|
||||
|
||||
# Create dummy MP4 files
|
||||
for ep in [1, 2, 3]:
|
||||
mp4_path = os.path.join(
|
||||
season_folder, f"Test Anime - S01E{ep:03d} - (German Dub).mp4"
|
||||
)
|
||||
with open(mp4_path, "w") as f:
|
||||
f.write("dummy")
|
||||
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
# Mock to return missing episodes (4, 5, 6)
|
||||
with patch.object(
|
||||
scanner,
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
return_value=({1: [4, 5, 6]}, "aniworld.to")
|
||||
):
|
||||
result = scanner.scan_single_series(
|
||||
key="test-anime",
|
||||
folder="Test Anime"
|
||||
)
|
||||
|
||||
# Should only show missing episodes
|
||||
assert result == {1: [4, 5, 6]}
|
||||
|
||||
Reference in New Issue
Block a user