"""Unit tests for folder_rename_service.py. These tests verify the core logic of the folder rename service in isolation, using temporary directories and mocked dependencies. """ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.server.services.scheduler.folder_rename_service import ( _cleanup_orphaned_folder, _compute_expected_folder_name, _is_series_being_downloaded, _parse_nfo_title_and_year, _update_database_paths, validate_and_rename_series_folders, ) class TestParseNfoTitleAndYear: """Tests for _parse_nfo_title_and_year.""" def test_parses_title_and_year(self, tmp_path: Path) -> None: nfo = tmp_path / "tvshow.nfo" nfo.write_text( "Attack on Titan2013" ) title, year = _parse_nfo_title_and_year(nfo) assert title == "Attack on Titan" assert year == "2013" def test_missing_title_returns_none(self, tmp_path: Path) -> None: nfo = tmp_path / "tvshow.nfo" nfo.write_text("2013") title, year = _parse_nfo_title_and_year(nfo) assert title is None assert year == "2013" def test_missing_year_returns_none(self, tmp_path: Path) -> None: nfo = tmp_path / "tvshow.nfo" nfo.write_text("Attack on Titan") title, year = _parse_nfo_title_and_year(nfo) assert title == "Attack on Titan" assert year is None def test_empty_title_returns_none(self, tmp_path: Path) -> None: nfo = tmp_path / "tvshow.nfo" nfo.write_text( " 2013" ) title, year = _parse_nfo_title_and_year(nfo) assert title is None assert year == "2013" def test_malformed_xml_returns_none(self, tmp_path: Path) -> None: nfo = tmp_path / "tvshow.nfo" nfo.write_text("not xml at all") title, year = _parse_nfo_title_and_year(nfo) assert title is None assert year is None class TestComputeExpectedFolderName: """Tests for _compute_expected_folder_name.""" def test_simple_title_and_year(self) -> None: result = _compute_expected_folder_name("Attack on Titan", "2013") assert result == "Attack on Titan (2013)" def test_sanitizes_invalid_chars(self) -> None: result = _compute_expected_folder_name("Show: Subtitle", "2020") assert result == "Show Subtitle (2020)" def test_sanitizes_slashes(self) -> None: result = _compute_expected_folder_name("A / B", "2021") assert result == "A B (2021)" def test_does_not_duplicate_year(self) -> None: result = _compute_expected_folder_name("86 Eighty Six (2021)", "2021") assert result == "86 Eighty Six (2021)" assert result.count("(2021)") == 1 def test_removes_duplicate_year_suffixes_bug_86_eighty_six(self) -> None: """Test the bug fix for duplicate year suffixes. Issue: "86 Eighty Six (2021) (2021) (2021) (2021) (2021)" should become "86 Eighty Six (2021)" """ result = _compute_expected_folder_name( "86 Eighty Six (2021) (2021) (2021) (2021) (2021)", "2021" ) assert result == "86 Eighty Six (2021)" assert result.count("(2021)") == 1 def test_removes_duplicate_year_suffixes_alma_chan(self) -> None: """Test the bug fix for duplicate year suffixes with long title. Issue: "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)" should become "Alma-chan Wants to Be a Family! (2025)" """ result = _compute_expected_folder_name( "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)", "2025", ) assert result == "Alma-chan Wants to Be a Family! (2025)" assert result.count("(2025)") == 1 def test_removes_duplicate_year_suffixes_bogus_skill(self) -> None: """Test the bug fix for duplicate year suffixes with very long title. Issue: Long title with duplicated years should be cleaned. """ result = _compute_expected_folder_name( "Bogus Skill Fruitmaster About That Time I Became Able to Eat " "Unlimited Numbers of Skill Fruits (That Kill You) (2025) (2025)", "2025", ) assert "(2025)" in result assert result.count("(2025)") == 1 def test_removes_multiple_different_year_suffixes(self) -> None: """Test that old duplicate years are removed and new one added.""" result = _compute_expected_folder_name( "Series (2020) (2020) (2020)", "2021" ) assert result == "Series (2021)" assert "(2020)" not in result assert result.count("(2021)") == 1 def test_handles_whitespace_with_duplicate_years(self) -> None: """Test that extra whitespace is removed along with duplicate years.""" result = _compute_expected_folder_name( "Series (2021) (2021) (2021) ", "2021" ) assert result == "Series (2021)" assert result.count("(2021)") == 1 assert not result.endswith(" ") def test_idempotent_multiple_calls(self) -> None: """Test that calling the function multiple times produces the same result.""" title = "86 Eighty Six (2021) (2021) (2021)" year = "2021" # First call result1 = _compute_expected_folder_name(title, year) # Second call with the result result2 = _compute_expected_folder_name(result1, year) # Third call with the result result3 = _compute_expected_folder_name(result2, year) # All results should be identical assert result1 == result2 == result3 assert result1 == "86 Eighty Six (2021)" assert result1.count("(2021)") == 1 class TestIsSeriesBeingDownloaded: """Tests for _is_series_being_downloaded.""" def test_no_active_download(self) -> None: mock_service = MagicMock() mock_service._active_download = None mock_service._pending_queue = [] with patch( "src.server.services.scheduler.folder_rename_service.get_download_service", return_value=mock_service, ): assert _is_series_being_downloaded("Some Show") is False def test_active_download_matches(self) -> None: mock_item = MagicMock() mock_item.serie_folder = "Some Show" mock_service = MagicMock() mock_service._active_download = mock_item mock_service._pending_queue = [] with patch( "src.server.services.scheduler.folder_rename_service.get_download_service", return_value=mock_service, ): assert _is_series_being_downloaded("Some Show") is True def test_pending_download_matches(self) -> None: mock_item = MagicMock() mock_item.serie_folder = "Some Show" mock_service = MagicMock() mock_service._active_download = None mock_service._pending_queue = [mock_item] with patch( "src.server.services.scheduler.folder_rename_service.get_download_service", return_value=mock_service, ): assert _is_series_being_downloaded("Some Show") is True def test_exception_returns_true_for_safety(self) -> None: with patch( "src.server.services.scheduler.folder_rename_service.get_download_service", side_effect=RuntimeError("boom"), ): assert _is_series_being_downloaded("Some Show") is True class TestUpdateDatabasePaths: """Tests for _update_database_paths.""" @pytest.mark.asyncio async def test_updates_series_folder(self, tmp_path: Path) -> None: anime_dir = tmp_path / "anime" anime_dir.mkdir() mock_series = MagicMock() mock_series.id = 1 mock_series.folder = "Old Name" with patch( "src.server.services.scheduler.folder_rename_service.get_db_session" ) as mock_get_db, patch( "src.server.services.scheduler.folder_rename_service.AnimeSeriesService" ) as mock_series_svc, patch( "src.server.services.scheduler.folder_rename_service.EpisodeService" ) as mock_episode_svc, patch( "src.server.services.scheduler.folder_rename_service.DownloadQueueService" ) as mock_queue_svc: mock_db = AsyncMock() mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False) mock_series_svc.get_by_folder = AsyncMock(return_value=mock_series) mock_series_svc.get_all = AsyncMock(return_value=[]) mock_series_svc.update = AsyncMock(return_value=mock_series) mock_episode_svc.get_by_series = AsyncMock(return_value=[]) mock_queue_svc.get_all = AsyncMock(return_value=[]) await _update_database_paths("Old Name", "New Name", anime_dir) mock_series_svc.update.assert_awaited_once_with( mock_db, 1, folder="New Name" ) @pytest.mark.asyncio async def test_updates_episode_file_paths(self, tmp_path: Path) -> None: anime_dir = tmp_path / "anime" anime_dir.mkdir() old_path = anime_dir / "Old Name" / "S01E01.mkv" new_path = anime_dir / "New Name" / "S01E01.mkv" mock_series = MagicMock() mock_series.id = 1 mock_series.folder = "Old Name" mock_episode = MagicMock() mock_episode.file_path = str(old_path) with patch( "src.server.services.scheduler.folder_rename_service.get_db_session" ) as mock_get_db, patch( "src.server.services.scheduler.folder_rename_service.AnimeSeriesService" ) as mock_series_svc, patch( "src.server.services.scheduler.folder_rename_service.EpisodeService" ) as mock_episode_svc, patch( "src.server.services.scheduler.folder_rename_service.DownloadQueueService" ) as mock_queue_svc: mock_db = AsyncMock() mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False) mock_series_svc.get_by_folder = AsyncMock(return_value=mock_series) mock_series_svc.get_all = AsyncMock(return_value=[]) mock_series_svc.update = AsyncMock(return_value=mock_series) mock_episode_svc.get_by_series = AsyncMock(return_value=[mock_episode]) mock_queue_svc.get_all = AsyncMock(return_value=[]) await _update_database_paths("Old Name", "New Name", anime_dir) assert mock_episode.file_path == str(new_path) class TestCleanupOrphanedFolder: """Tests for _cleanup_orphaned_folder.""" def test_returns_false_when_old_folder_does_not_exist(self, tmp_path: Path) -> None: old_path = tmp_path / "nonexistent" new_path = tmp_path / "new" result = _cleanup_orphaned_folder(old_path, new_path) assert result is False def test_deletes_empty_folder(self, tmp_path: Path) -> None: old_path = tmp_path / "empty_orphan" old_path.mkdir() new_path = tmp_path / "new" new_path.mkdir() result = _cleanup_orphaned_folder(old_path, new_path) assert result is True assert not old_path.exists() def test_moves_files_and_deletes_folder(self, tmp_path: Path) -> None: old_path = tmp_path / "old_orphan" old_path.mkdir() new_path = tmp_path / "new" new_path.mkdir() file1 = old_path / "S01E01.mkv" file1.write_text("episode 1") file2 = old_path / "S01E02.mkv" file2.write_text("episode 2") result = _cleanup_orphaned_folder(old_path, new_path) assert result is True assert not old_path.exists() assert (new_path / "S01E01.mkv").exists() assert (new_path / "S01E02.mkv").exists() def test_dry_run_does_not_delete_empty_folder(self, tmp_path: Path) -> None: old_path = tmp_path / "empty_orphan" old_path.mkdir() new_path = tmp_path / "new" new_path.mkdir() result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True) assert result is True assert old_path.exists() def test_dry_run_does_not_move_files(self, tmp_path: Path) -> None: old_path = tmp_path / "old_orphan" old_path.mkdir() new_path = tmp_path / "new" new_path.mkdir() file1 = old_path / "S01E01.mkv" file1.write_text("episode 1") result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True) assert result is True assert old_path.exists() assert not (new_path / "S01E01.mkv").exists() def test_handles_permission_error_gracefully(self, tmp_path: Path) -> None: old_path = tmp_path / "permission_denied" old_path.mkdir() new_path = tmp_path / "new" new_path.mkdir() # Simulate permission error by patching rmdir with patch.object(Path, "rmdir", side_effect=PermissionError("Access denied")): result = _cleanup_orphaned_folder(old_path, new_path) assert result is False class TestValidateAndRenameSeriesFolders: """Integration-style tests for validate_and_rename_series_folders.""" @pytest.mark.asyncio async def test_no_anime_directory(self) -> None: with patch( "src.server.services.scheduler.folder_rename_service.settings.anime_directory", "", ): stats = await validate_and_rename_series_folders() assert stats == {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} @pytest.mark.asyncio async def test_renames_folder_when_name_differs(self, tmp_path: Path) -> None: anime_dir = tmp_path / "anime" anime_dir.mkdir() series_dir = anime_dir / "Attack on Titan" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "Attack on Titan2013" ) with patch( "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ), patch( "src.server.services.scheduler.folder_rename_service._update_database_paths", new_callable=AsyncMock, ) as mock_update_db: stats = await validate_and_rename_series_folders() assert stats["scanned"] == 1 assert stats["renamed"] == 1 assert stats["skipped"] == 0 assert stats["errors"] == 0 assert not series_dir.exists() assert (anime_dir / "Attack on Titan (2013)").is_dir() mock_update_db.assert_awaited_once() @pytest.mark.asyncio async def test_skips_when_name_already_correct(self, tmp_path: Path) -> None: anime_dir = tmp_path / "anime" anime_dir.mkdir() series_dir = anime_dir / "Attack on Titan (2013)" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "Attack on Titan2013" ) with patch( "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ): stats = await validate_and_rename_series_folders() assert stats["scanned"] == 1 assert stats["renamed"] == 0 assert stats["skipped"] == 0 assert stats["errors"] == 0 assert series_dir.is_dir() @pytest.mark.asyncio async def test_skips_missing_title_or_year(self, tmp_path: Path) -> None: anime_dir = tmp_path / "anime" anime_dir.mkdir() series_dir = anime_dir / "Incomplete" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "Incomplete" ) with patch( "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ): stats = await validate_and_rename_series_folders() assert stats["scanned"] == 1 assert stats["renamed"] == 0 assert stats["skipped"] == 1 assert stats["errors"] == 0 @pytest.mark.asyncio async def test_skips_when_download_active(self, tmp_path: Path) -> None: anime_dir = tmp_path / "anime" anime_dir.mkdir() series_dir = anime_dir / "Attack on Titan" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "Attack on Titan2013" ) with patch( "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=True, ): stats = await validate_and_rename_series_folders() assert stats["scanned"] == 1 assert stats["renamed"] == 0 assert stats["skipped"] == 1 assert stats["errors"] == 0 assert series_dir.is_dir() @pytest.mark.asyncio async def test_duplicate_target_folder_source_removed_and_db_deleted(self, tmp_path: Path) -> None: """When target folder exists, source folder should be removed and its DB record deleted.""" anime_dir = tmp_path / "anime" anime_dir.mkdir() series_dir = anime_dir / "Attack on Titan" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "Attack on Titan2013" ) # Pre-create the target folder to simulate a duplicate target_dir = anime_dir / "Attack on Titan (2013)" target_dir.mkdir() mock_db = AsyncMock() mock_session = AsyncMock() mock_db.__aenter__.return_value = mock_session mock_db.__aexit__.return_value = None with patch( "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ), patch( "src.server.services.scheduler.folder_rename_service.get_db_session", return_value=mock_db, ), patch( "src.server.services.scheduler.folder_rename_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ), patch( "src.server.services.scheduler.folder_rename_service.AnimeSeriesService.get_all", new_callable=AsyncMock, return_value=[], ): stats = await validate_and_rename_series_folders() # Source folder removed, target survives assert not series_dir.exists() assert target_dir.is_dir() # Duplicate resolved: counts as renamed (source removed, target kept) assert stats["scanned"] == 1 assert stats["renamed"] == 1 assert stats["skipped"] == 0 assert stats["errors"] == 0 @pytest.mark.asyncio async def test_counts_multiple_folders(self, tmp_path: Path) -> None: anime_dir = tmp_path / "anime" anime_dir.mkdir() # Folder 1: needs rename d1 = anime_dir / "Show A" d1.mkdir() (d1 / "tvshow.nfo").write_text( "Show A2020" ) # Folder 2: already correct d2 = anime_dir / "Show B (2021)" d2.mkdir() (d2 / "tvshow.nfo").write_text( "Show B2021" ) # Folder 3: missing year d3 = anime_dir / "Show C" d3.mkdir() (d3 / "tvshow.nfo").write_text("Show C") with patch( "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ), patch( "src.server.services.scheduler.folder_rename_service._update_database_paths", new_callable=AsyncMock, ): stats = await validate_and_rename_series_folders() assert stats["scanned"] == 3 assert stats["renamed"] == 1 assert stats["skipped"] == 1 assert stats["errors"] == 0 assert not d1.exists() assert (anime_dir / "Show A (2020)").is_dir() assert d2.is_dir() assert d3.is_dir() @pytest.mark.asyncio async def test_dry_run_does_not_rename_folders(self, tmp_path: Path) -> None: anime_dir = tmp_path / "anime" anime_dir.mkdir() series_dir = anime_dir / "Attack on Titan" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "Attack on Titan2013" ) with patch( "src.server.services.scheduler.folder_rename_service.settings.anime_directory", str(anime_dir), ), patch( "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", return_value=False, ): stats = await validate_and_rename_series_folders(dry_run=True) assert stats["scanned"] == 1 assert stats["renamed"] == 1 assert stats["skipped"] == 0 assert stats["errors"] == 0 # Original folder should still exist (not renamed in dry-run) assert series_dir.is_dir() assert not (anime_dir / "Attack on Titan (2013)").exists()