When the year-suffixed target folder already exists on disk, both
FolderNamingService and AnimeService.rename_folder_if_needed used to
silently bail out. The bare folder (e.g. 'Ultraman') was left next to
the year-suffixed one ('Ultraman (2019)'), producing the symptom
'reports series like Ultraman as added twice' — the DB has one row but
the filesystem has two folders holding the same content.
Fix: when the target already exists, merge the source's contents into
the target (target version wins on file conflicts; source copies are
removed so cleanup succeeds), remove the now-empty source directory,
update DB row + in-memory cache. Plain rename path is unchanged.
Also fixes a latent TypeError in rename_folder_if_needed where
self._directory (a str) was used with the '/' operator. Production
behavior was that any rename through that method raised and was
swallowed by the caller's try/except, leaving the bare folder
untouched. The new path builds Path objects from the string base.
Tests:
- Replaced test_skips_when_target_folder_already_exists (which
codified the bug) with three tests that cover the new merge
contract: clean merge, no-overwrite, and empty-source removal.
- Added tests/unit/test_rename_folder_if_needed.py covering the
same scenarios plus the str-directory regression. All seven go
red on the unfixed code and green with the fix.
Fixes the 'Ultraman' / 'Ultraman (2019)' duplicate-folder bug.
474 lines
20 KiB
Python
474 lines
20 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_merges_source_into_existing_target_when_target_has_no_overlap(
|
|
self, tmp_path, mock_db_session, mock_series, mock_settings
|
|
):
|
|
"""Source 'Naruto' with seasons/episodes, target 'Naruto (1999)' exists empty.
|
|
|
|
Source files are moved into target. Empty source directory is removed.
|
|
DB folder is updated to target. Result is success (renamed).
|
|
"""
|
|
anime_dir = tmp_path
|
|
source = anime_dir / "Naruto"
|
|
target = anime_dir / "Naruto (1999)"
|
|
source.mkdir()
|
|
target.mkdir()
|
|
(source / "Season 1").mkdir()
|
|
(source / "Season 1" / "ep01.mp4").touch()
|
|
(source / "Season 2").mkdir()
|
|
(source / "Season 2" / "ep01.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()
|
|
|
|
# Outcome: renamed (not error) — source merged into target
|
|
assert report.renamed == 1
|
|
assert report.errors == 0
|
|
assert report.results[0].success is True
|
|
assert report.results[0].skipped is False
|
|
assert report.results[0].new_folder == "Naruto (1999)"
|
|
|
|
# Source folder gone
|
|
assert not source.exists(), "Source folder should be removed after merge"
|
|
# Target folder has merged content
|
|
assert (target / "Season 1" / "ep01.mp4").exists()
|
|
assert (target / "Season 2" / "ep01.mp4").exists()
|
|
# DB updated
|
|
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merges_only_missing_seasons_preserving_existing_target_files(
|
|
self, tmp_path, mock_db_session, mock_series, mock_settings
|
|
):
|
|
"""Source has S01 ep01, target already has S01 ep01 (different content).
|
|
|
|
Existing target files are kept. Source's S01 ep01 is NOT overwritten.
|
|
Source's S02 (new) is moved. Empty source is removed.
|
|
"""
|
|
anime_dir = tmp_path
|
|
source = anime_dir / "Naruto"
|
|
target = anime_dir / "Naruto (1999)"
|
|
source.mkdir()
|
|
target.mkdir()
|
|
|
|
# Target already has S01 with one episode
|
|
(target / "Season 1").mkdir()
|
|
target_existing = target / "Season 1" / "ep01.mp4"
|
|
target_existing.write_text("target-version")
|
|
|
|
# Source has S01 with same episode (different content) and S02
|
|
(source / "Season 1").mkdir()
|
|
source_conflict = source / "Season 1" / "ep01.mp4"
|
|
source_conflict.write_text("source-version")
|
|
(source / "Season 2").mkdir()
|
|
(source / "Season 2" / "ep01.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), \
|
|
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()
|
|
|
|
# Renamed (source effectively absorbed)
|
|
assert report.renamed == 1
|
|
assert not source.exists(), "Source should be removed after merge"
|
|
# Target S01 ep01 keeps the target version (not overwritten)
|
|
assert target_existing.read_text() == "target-version"
|
|
# New S02 moved in
|
|
assert (target / "Season 2" / "ep01.mp4").exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_removes_empty_source_folder_when_target_exists(
|
|
self, tmp_path, mock_db_session, mock_series, mock_settings
|
|
):
|
|
"""Source folder exists but is empty, target already exists.
|
|
|
|
Source should be removed silently, DB updated, success.
|
|
"""
|
|
anime_dir = tmp_path
|
|
source = anime_dir / "Naruto"
|
|
target = anime_dir / "Naruto (1999)"
|
|
source.mkdir()
|
|
target.mkdir()
|
|
(target / "tvshow.nfo").write_text("existing nfo")
|
|
|
|
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.errors == 0
|
|
assert not source.exists(), "Empty source should be removed"
|
|
assert (target / "tvshow.nfo").exists(), "Target content preserved"
|
|
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
|
|
|
@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)"
|