Runs after NFO refresh during scheduled rescans. Renames folders that are missing a year (e.g. 'Naruto' → 'Naruto (1999)') using the year from the database record. Safety: _build_target_folder() always strips any existing year suffix first, preventing double/triple year accumulation like 'Naruto (1999) (1999) (1999)'. Changes: - New FolderNamingService (folder_naming_service.py) with safe target name construction, DB update, and in-memory cache update - New SchedulerConfig field: folder_naming_after_nfo_scan (default True) - Integrated as step 3 in scheduler _perform_rescan() after NFO scan - Runtime UI: existing 'folder-scan-enabled' checkbox in index.html now wired to toggle the feature (app.js + scheduler-config.js) - Setup screen: new checkbox in setup.html Scheduler Settings section - API: scheduler config endpoint returns all scan toggles - Tests: 39 unit tests covering static helpers, rename logic, safety guard, and integration cases (folder_naming_service.py) - Docs: testing guide updated with FolderNamingService examples
345 lines
14 KiB
Python
345 lines
14 KiB
Python
"""Tests for FolderNamingService."""
|
|
import os
|
|
import re
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.server.services.folder_naming_service import (
|
|
FolderNamingService,
|
|
FolderRenameResult,
|
|
FolderNamingReport,
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Static method tests
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestFolderHasYear:
|
|
"""Tests for _folder_has_year."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("folder", "expected"),
|
|
[
|
|
("Naruto", False),
|
|
("Naruto (2020)", True),
|
|
("Attack on Titan (2013)", True),
|
|
("", False),
|
|
("One Piece", False),
|
|
("(2020)", True),
|
|
("Naruto 2020", False), # no parentheses
|
|
("Naruto (20)", False), # too short
|
|
("Naruto (abcd)", False), # not digits
|
|
],
|
|
)
|
|
def test_folder_has_year(self, folder, expected):
|
|
assert FolderNamingService._folder_has_year(folder) == expected
|
|
|
|
|
|
class TestExtractYearFromFolderName:
|
|
"""Tests for _extract_year_from_folder_name."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("folder", "expected"),
|
|
[
|
|
("Naruto", None),
|
|
("Naruto (2020)", 2020),
|
|
("Attack on Titan (2013)", 2013),
|
|
("", None),
|
|
("(2020)", 2020),
|
|
("Title (1800)", None), # out of range
|
|
("Title (2200)", None), # out of range
|
|
("Title (2020) - Extra", 2020), # year in middle
|
|
],
|
|
)
|
|
def test_extract_year(self, folder, expected):
|
|
assert FolderNamingService._extract_year_from_folder_name(folder) == expected
|
|
|
|
|
|
class TestExtractTitleFromFolderName:
|
|
"""Tests for _extract_title_from_folder_name."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("folder", "expected"),
|
|
[
|
|
("Naruto", "Naruto"),
|
|
("Naruto (2020)", "Naruto"),
|
|
("Attack on Titan (2013)", "Attack on Titan"),
|
|
("", ""),
|
|
("Naruto (2020) ", "Naruto"), # extra spaces
|
|
("Title (2020) - Extra", "Title (2020) - Extra"), # year in middle not removed
|
|
],
|
|
)
|
|
def test_extract_title(self, folder, expected):
|
|
assert FolderNamingService._extract_title_from_folder_name(folder) == expected
|
|
|
|
|
|
class TestBuildTargetFolder:
|
|
"""Tests for _build_target_folder — the critical double-year prevention."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("folder", "year", "expected"),
|
|
[
|
|
# No year → add year
|
|
("Naruto", 1999, "Naruto (1999)"),
|
|
("One Piece", 1999, "One Piece (1999)"),
|
|
# Has year → strip and replace
|
|
("Naruto (2020)", 1999, "Naruto (1999)"),
|
|
("Naruto (2020)", 2020, "Naruto (2020)"),
|
|
# Has year with extra spaces
|
|
("Naruto (2020)", 1999, "Naruto (1999)"),
|
|
# Title with inner parens (not a year) → preserved
|
|
("Dragon Ball (Super)", 1989, "Dragon Ball (Super) (1989)"),
|
|
],
|
|
)
|
|
def test_build_target_folder(self, folder, year, expected):
|
|
assert FolderNamingService._build_target_folder(folder, year) == expected
|
|
|
|
def test_repeated_calls_do_not_accumulate_years(self):
|
|
"""Ensures safe: Naruto -> (1999) -> (1999) stays (1999), not (1999)(1999)."""
|
|
folder = "Naruto"
|
|
year = 1999
|
|
step1 = FolderNamingService._build_target_folder(folder, year)
|
|
step2 = FolderNamingService._build_target_folder(step1, year)
|
|
step3 = FolderNamingService._build_target_folder(step2, year)
|
|
assert step1 == "Naruto (1999)"
|
|
assert step2 == "Naruto (1999)"
|
|
assert step3 == "Naruto (1999)"
|
|
|
|
def test_already_yeared_folder_year_replaced_not_appended(self):
|
|
"""Folder 'Naruto (2020)' with year=1999 → 'Naruto (1999)', not 'Naruto (2020) (1999)'."""
|
|
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
|
|
assert result == "Naruto (1999)"
|
|
assert "2020" not in result
|
|
|
|
def test_different_years_each_call_is_safe(self):
|
|
"""Multiple different years applied sequentially always produce clean name."""
|
|
folder = "Naruto"
|
|
r1 = FolderNamingService._build_target_folder(folder, 1999)
|
|
r2 = FolderNamingService._build_target_folder(r1, 1997)
|
|
r3 = FolderNamingService._build_target_folder(r2, 1999)
|
|
assert r1 == "Naruto (1999)"
|
|
assert r2 == "Naruto (1997)"
|
|
assert r3 == "Naruto (1999)"
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Service tests (need mock DB + filesystem)
|
|
# -----------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def mock_db_session():
|
|
"""Mock async DB session."""
|
|
with patch("src.server.services.folder_naming_service._get_db_session") as mock:
|
|
session = AsyncMock()
|
|
mock.return_value.__aenter__.return_value = session
|
|
mock.return_value.__aexit__.return_value = None
|
|
yield session
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_series(flash=False):
|
|
"""Factory for mock anime series objects."""
|
|
def _make(key, folder, year):
|
|
series = MagicMock()
|
|
series.key = key
|
|
series.folder = folder
|
|
series.year = year
|
|
return series
|
|
return _make
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_settings(tmp_path):
|
|
"""Mock settings with a temp anime directory."""
|
|
with patch("src.server.services.folder_naming_service.settings") as mock:
|
|
mock.anime_directory = str(tmp_path)
|
|
yield mock
|
|
|
|
|
|
class TestFolderNamingServiceIntegration:
|
|
"""Integration tests with mocked filesystem and DB."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_when_folder_already_has_year(
|
|
self, mock_db_session, mock_series, mock_settings
|
|
):
|
|
"""Folder 'Naruto (1999)' with DB year=1999 → skipped."""
|
|
series = mock_series("key1", "Naruto (1999)", 1999)
|
|
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
|
|
|
# Mock AnimeSeriesService.get_all
|
|
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
|
mock_get_all.return_value = [series]
|
|
|
|
service = FolderNamingService()
|
|
report = await service.run()
|
|
|
|
assert report.total == 1
|
|
assert report.skipped == 1
|
|
assert report.renamed == 0
|
|
assert report.errors == 0
|
|
assert report.results[0].reason == "folder already has year"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_when_db_has_no_year(
|
|
self, mock_db_session, mock_series, mock_settings
|
|
):
|
|
"""Folder 'Naruto' with DB year=None → skipped."""
|
|
series = mock_series("key1", "Naruto", None)
|
|
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
|
|
|
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
|
mock_get_all.return_value = [series]
|
|
|
|
service = FolderNamingService()
|
|
report = await service.run()
|
|
|
|
assert report.skipped == 1
|
|
assert report.results[0].reason == "no year in DB record"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_renames_folder_and_updates_db(
|
|
self, tmp_path, mock_db_session, mock_series, mock_settings
|
|
):
|
|
"""Folder 'Naruto' with DB year=1999 → renames to 'Naruto (1999)'."""
|
|
anime_dir = tmp_path
|
|
(anime_dir / "Naruto").mkdir()
|
|
(anime_dir / "Naruto" / "episode1.mp4").touch()
|
|
|
|
series = mock_series("key1", "Naruto", 1999)
|
|
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
|
|
|
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
|
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
|
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
|
|
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
|
|
|
mock_get_all.return_value = [series]
|
|
db_series = MagicMock()
|
|
db_series.id = 42
|
|
mock_get_by_key.return_value = db_series
|
|
|
|
app_instance = MagicMock()
|
|
app_instance.list.keyDict = {"key1": MagicMock()}
|
|
mock_get_app.return_value = app_instance
|
|
|
|
mock_settings.anime_directory = str(anime_dir)
|
|
|
|
service = FolderNamingService()
|
|
report = await service.run()
|
|
|
|
assert report.renamed == 1
|
|
assert report.skipped == 0
|
|
assert report.errors == 0
|
|
assert report.results[0].new_folder == "Naruto (1999)"
|
|
assert (anime_dir / "Naruto (1999)").exists()
|
|
assert not (anime_dir / "Naruto").exists()
|
|
mock_update.assert_called_once()
|
|
call_kwargs = mock_update.call_args.kwargs
|
|
assert call_kwargs["folder"] == "Naruto (1999)"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_when_target_folder_already_exists(
|
|
self, tmp_path, mock_db_session, mock_series, mock_settings
|
|
):
|
|
"""If 'Naruto (1999)' already exists, rename is skipped."""
|
|
anime_dir = tmp_path
|
|
(anime_dir / "Naruto").mkdir()
|
|
(anime_dir / "Naruto (1999)").mkdir() # target already exists
|
|
|
|
series = mock_series("key1", "Naruto", 1999)
|
|
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
|
|
|
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
|
mock_get_all.return_value = [series]
|
|
mock_settings.anime_directory = str(anime_dir)
|
|
|
|
service = FolderNamingService()
|
|
report = await service.run()
|
|
|
|
assert report.errors == 1
|
|
assert report.renamed == 0
|
|
assert report.results[0].reason == "target folder already exists on disk"
|
|
assert (anime_dir / "Naruto").exists() # source not moved
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):
|
|
"""Safety guard triggers when _build_target_folder produces wrong year.
|
|
|
|
Uses a folder without a detectible year so _process_series proceeds to
|
|
build a target, then the safety re-check finds a mismatch.
|
|
"""
|
|
anime_dir = tmp_path
|
|
(anime_dir / "Naruto").mkdir()
|
|
|
|
series = mock_series("key1", "Naruto", 1999)
|
|
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
|
|
|
# Patch _build_target_folder to return a folder whose extracted year != DB year
|
|
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
|
patch.object(FolderNamingService, "_build_target_folder", return_value="Naruto (2020)"):
|
|
mock_get_all.return_value = [series]
|
|
mock_settings.anime_directory = str(anime_dir)
|
|
|
|
service = FolderNamingService()
|
|
report = await service.run()
|
|
|
|
# Should be skipped by safety guard
|
|
assert report.results[0].skipped is True
|
|
assert "safety guard" in report.results[0].reason.lower()
|
|
# Folder should NOT have been renamed
|
|
assert (anime_dir / "Naruto").exists()
|
|
assert not (anime_dir / "Naruto (2020)").exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_series_mixed_results(
|
|
self, tmp_path, mock_db_session, mock_series, mock_settings
|
|
):
|
|
"""Series 1 needs rename, series 2 is skipped (has year), series 3 has no year."""
|
|
anime_dir = tmp_path
|
|
(anime_dir / "Naruto").mkdir()
|
|
(anime_dir / "One Piece (1999)").mkdir()
|
|
(anime_dir / "Bleach").mkdir()
|
|
|
|
series = [
|
|
mock_series("key1", "Naruto", 1999),
|
|
mock_series("key2", "One Piece (1999)", 1999),
|
|
mock_series("key3", "Bleach", None),
|
|
]
|
|
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
|
|
|
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
|
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
|
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock), \
|
|
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
|
|
|
mock_get_all.return_value = series
|
|
db_series = MagicMock()
|
|
db_series.id = 1
|
|
mock_get_by_key.return_value = db_series
|
|
|
|
app_instance = MagicMock()
|
|
app_instance.list.keyDict = {"key1": MagicMock()}
|
|
mock_get_app.return_value = app_instance
|
|
mock_settings.anime_directory = str(anime_dir)
|
|
|
|
service = FolderNamingService()
|
|
report = await service.run()
|
|
|
|
assert report.total == 3
|
|
assert report.renamed == 1 # Naruto → Naruto (1999)
|
|
assert report.skipped == 2 # One Piece (has year), Bleach (no year)
|
|
assert report.errors == 0
|
|
|
|
|
|
class TestFolderNamingReport:
|
|
def test_to_dict(self):
|
|
result = FolderRenameResult(key="k1", old_folder="Naruto", new_folder="Naruto (1999)", success=True, skipped=False)
|
|
report = FolderNamingReport(total=1, renamed=1, skipped=0, errors=0, results=[result])
|
|
d = report.to_dict()
|
|
assert d["total"] == 1
|
|
assert d["renamed"] == 1
|
|
assert d["results"][0]["new_folder"] == "Naruto (1999)"
|