Files
Aniworld/tests/unit/test_folder_naming_service.py
Lukas 0f872276dd fix: merge bare folder into year-suffixed one instead of bailing
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.
2026-08-28 21:42:55 +02:00

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)"