"""Tests for AnimeService.rename_folder_if_needed. The behavior under test: when both the source folder (without year) and the target folder (with year) exist on disk, the rename must not silently bail out — it must merge the source into the target and remove the empty source. This is what prevents the "Ultraman" + "Ultraman (2019)" duplicate-folder problem reported by users. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.server.services.anime_service import AnimeService @pytest.fixture def anime_service_with_dir(tmp_path): """Create AnimeService pointing at a temp directory.""" mock_app = MagicMock() mock_app.directory_to_search = str(tmp_path) mock_app.list.keyDict = {} progress = MagicMock() service = AnimeService(series_app=mock_app, progress_service=progress) return service, tmp_path class TestRenameFolderIfNeededMerge: """Tests for the merge-into-existing-target behavior.""" @pytest.mark.asyncio async def test_merges_seasons_when_target_exists( self, anime_service_with_dir ): service, anime_dir = anime_service_with_dir source = anime_dir / "Naruto" target = anime_dir / "Naruto (1999)" source.mkdir() (source / "Season 1").mkdir() (source / "Season 1" / "ep01.mp4").touch() target.mkdir() db = AsyncMock() db_series = MagicMock() db_series.id = 1 db_series.folder = "Naruto" with patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=db_series, ), patch( "src.server.database.service.AnimeSeriesService.update", new_callable=AsyncMock, ) as mock_update: ok = await service.rename_folder_if_needed( key="naruto", current_folder="Naruto", target_folder="Naruto (1999)", db=db, ) # Outcome: rename "succeeded" (target now contains source content) assert ok is True assert not source.exists(), "Source should be removed after merge" assert (target / "Season 1" / "ep01.mp4").exists() # DB row updated to target assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)" @pytest.mark.asyncio async def test_does_not_overwrite_existing_target_files( self, anime_service_with_dir ): """If target already has an episode file, the source copy is removed (target version wins; no duplicate retained). """ service, anime_dir = anime_service_with_dir source = anime_dir / "Naruto" target = anime_dir / "Naruto (1999)" source.mkdir() target.mkdir() (target / "Season 1").mkdir() target_existing = target / "Season 1" / "ep01.mp4" target_existing.write_text("target-version") (source / "Season 1").mkdir() source_conflict = source / "Season 1" / "ep01.mp4" source_conflict.write_text("source-version") db = AsyncMock() db_series = MagicMock() db_series.id = 1 with patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=db_series, ), patch( "src.server.database.service.AnimeSeriesService.update", new_callable=AsyncMock, ): ok = await service.rename_folder_if_needed( key="naruto", current_folder="Naruto", target_folder="Naruto (1999)", db=db, ) assert ok is True # Target version preserved assert target_existing.read_text() == "target-version" # Source folder removed (after merge, even with skipped conflicts) assert not source.exists() @pytest.mark.asyncio async def test_removes_empty_source_when_target_exists( self, anime_service_with_dir ): service, anime_dir = anime_service_with_dir source = anime_dir / "Naruto" target = anime_dir / "Naruto (1999)" source.mkdir() target.mkdir() (target / "tvshow.nfo").write_text("kept") db = AsyncMock() db_series = MagicMock() db_series.id = 1 with patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=db_series, ), patch( "src.server.database.service.AnimeSeriesService.update", new_callable=AsyncMock, ) as mock_update: ok = await service.rename_folder_if_needed( key="naruto", current_folder="Naruto", target_folder="Naruto (1999)", db=db, ) assert ok is True assert not source.exists() assert (target / "tvshow.nfo").read_text() == "kept" assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)" @pytest.mark.asyncio async def test_simple_rename_when_target_does_not_exist( self, anime_service_with_dir ): """Regression: plain rename (no merge needed) still works.""" service, anime_dir = anime_service_with_dir source = anime_dir / "Naruto" source.mkdir() (source / "Season 1").mkdir() (source / "Season 1" / "ep01.mp4").touch() db = AsyncMock() db_series = MagicMock() db_series.id = 1 with patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=db_series, ), patch( "src.server.database.service.AnimeSeriesService.update", new_callable=AsyncMock, ) as mock_update: ok = await service.rename_folder_if_needed( key="naruto", current_folder="Naruto", target_folder="Naruto (1999)", db=db, ) assert ok is True assert not source.exists() assert (anime_dir / "Naruto (1999)" / "Season 1" / "ep01.mp4").exists() assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)" @pytest.mark.asyncio async def test_no_op_when_source_and_target_same( self, anime_service_with_dir ): """Regression: same-name case returns False without touching disk.""" service, anime_dir = anime_service_with_dir source = anime_dir / "Naruto (1999)" source.mkdir() ok = await service.rename_folder_if_needed( key="naruto", current_folder="Naruto (1999)", target_folder="Naruto (1999)", db=None, ) assert ok is False assert source.exists() @pytest.mark.asyncio async def test_no_op_when_source_missing(self, anime_service_with_dir): """Regression: source missing on disk returns False without error.""" service, anime_dir = anime_service_with_dir ok = await service.rename_folder_if_needed( key="naruto", current_folder="Naruto", target_folder="Naruto (1999)", db=None, ) assert ok is False @pytest.mark.asyncio async def test_path_typesafe_with_string_directory(self, tmp_path): """Regression: directory_to_search being a string (not Path) works. Original code did `self._directory / current_folder` which raised TypeError when _directory was a str. This was silently swallowed by the caller's try/except, leaving the rename undone. """ mock_app = MagicMock() mock_app.directory_to_search = str(tmp_path) # string, not Path mock_app.list.keyDict = {} progress = MagicMock() service = AnimeService(series_app=mock_app, progress_service=progress) source = tmp_path / "Naruto" target = tmp_path / "Naruto (1999)" source.mkdir() target.mkdir() ok = await service.rename_folder_if_needed( key="naruto", current_folder="Naruto", target_folder="Naruto (1999)", db=None, ) # Must not raise; must succeed (merge path). assert ok is True assert not source.exists()