- 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
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""
|
|
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
|