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