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