"""Unit tests for FolderScanService (Tasks 1.2–1.5). Covers: - Prerequisites checking (TMDB key, anime directory) - NFO repair integration (Task 1.3) - Folder rename validation (Task 1.4) - Poster check and download (Task 1.5) - Exception handling and semaphore usage """ from __future__ import annotations import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from src.server.services.folder_scan_service import ( _POSTER_DOWNLOAD_SEMAPHORE, _TMDB_SEMAPHORE, FolderScanService, FolderScanServiceError, perform_nfo_repair_scan, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def folder_scan_service() -> FolderScanService: """Return a fresh FolderScanService instance.""" return FolderScanService() @pytest.fixture def mock_settings(tmp_path: Path): """Return a mock settings object with valid prerequisites.""" mock = MagicMock() mock.tmdb_api_key = "test-api-key" mock.anime_directory = str(tmp_path) mock.nfo_download_poster = True return mock # --------------------------------------------------------------------------- # 1.2 – Skeleton / prerequisites # --------------------------------------------------------------------------- class TestPrerequisites: """Test _prerequisites_met checks.""" def test_prerequisites_met(self, folder_scan_service, tmp_path): """All prerequisites present → True.""" with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.tmdb_api_key = "key" mock_settings.anime_directory = str(tmp_path) assert folder_scan_service._prerequisites_met() is True def test_missing_tmdb_key(self, folder_scan_service, tmp_path): """Missing TMDB API key → False.""" with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.tmdb_api_key = None mock_settings.anime_directory = str(tmp_path) assert folder_scan_service._prerequisites_met() is False def test_missing_anime_directory(self, folder_scan_service): """Missing anime_directory → False.""" with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.tmdb_api_key = "key" mock_settings.anime_directory = None assert folder_scan_service._prerequisites_met() is False def test_anime_directory_not_found(self, folder_scan_service, tmp_path): """anime_directory points to non-existent path → False.""" non_existent = tmp_path / "does_not_exist" with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.tmdb_api_key = "key" mock_settings.anime_directory = str(non_existent) assert folder_scan_service._prerequisites_met() is False class TestRunFolderScanPrerequisites: """Test run_folder_scan skips when prerequisites not met.""" @pytest.mark.asyncio async def test_skips_when_prerequisites_missing(self, folder_scan_service): """If _prerequisites_met returns False, scan exits early.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=False ), patch( "src.server.services.folder_scan_service.perform_nfo_repair_scan" ) as mock_repair: await folder_scan_service.run_folder_scan() mock_repair.assert_not_called() @pytest.mark.asyncio async def test_logs_start_and_completion(self, folder_scan_service, tmp_path): """Scan logs start and completion when prerequisites are met.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( "src.server.services.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( "src.server.services.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch.object( folder_scan_service, "check_and_download_missing_posters", new_callable=AsyncMock, return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}, ): # Should not raise await folder_scan_service.run_folder_scan() @pytest.mark.asyncio async def test_catches_unhandled_exceptions(self, folder_scan_service): """Unhandled exceptions are caught and logged, not re-raised.""" with patch.object( folder_scan_service, "_prerequisites_met", side_effect=RuntimeError("boom"), ): # Must NOT raise await folder_scan_service.run_folder_scan() # --------------------------------------------------------------------------- # 1.3 – NFO repair integration # --------------------------------------------------------------------------- class TestNfoRepairIntegration: """Test perform_nfo_repair_scan is called inside run_folder_scan.""" @pytest.mark.asyncio async def test_calls_perform_nfo_repair_scan(self, folder_scan_service, tmp_path): """run_folder_scan must call perform_nfo_repair_scan.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( "src.server.services.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ) as mock_repair, patch( "src.server.services.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch.object( folder_scan_service, "check_and_download_missing_posters", new_callable=AsyncMock, return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}, ): await folder_scan_service.run_folder_scan() mock_repair.assert_awaited_once_with(background_loader=None) @pytest.mark.asyncio async def test_nfo_repair_failure_does_not_crash_scan( self, folder_scan_service, tmp_path ): """If perform_nfo_repair_scan raises, the broad except catches it and the scan stops — remaining steps are NOT invoked.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( "src.server.services.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, side_effect=RuntimeError("repair failed"), ) as mock_repair, patch( "src.server.services.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ) as mock_rename, patch.object( folder_scan_service, "check_and_download_missing_posters", new_callable=AsyncMock, return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}, ): await folder_scan_service.run_folder_scan() mock_repair.assert_awaited_once() # Broad except stops the scan; rename/poster are skipped mock_rename.assert_not_called() # --------------------------------------------------------------------------- # 1.4 – Folder rename integration # --------------------------------------------------------------------------- class TestFolderRenameIntegration: """Test validate_and_rename_series_folders is called and stats logged.""" @pytest.mark.asyncio async def test_calls_folder_rename_service(self, folder_scan_service, tmp_path): """run_folder_scan must call validate_and_rename_series_folders.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( "src.server.services.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( "src.server.services.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1}, ) as mock_rename, patch.object( folder_scan_service, "check_and_download_missing_posters", new_callable=AsyncMock, return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}, ): await folder_scan_service.run_folder_scan() mock_rename.assert_awaited_once() @pytest.mark.asyncio async def test_folder_rename_failure_does_not_crash_scan( self, folder_scan_service, tmp_path ): """If validate_and_rename_series_folders raises, the broad except catches it and the scan stops — poster check is NOT invoked.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( "src.server.services.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( "src.server.services.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, side_effect=RuntimeError("rename failed"), ), patch.object( folder_scan_service, "check_and_download_missing_posters", new_callable=AsyncMock, return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}, ) as mock_poster: await folder_scan_service.run_folder_scan() # Broad except stops the scan; poster check is skipped mock_poster.assert_not_called() # --------------------------------------------------------------------------- # 1.5 – Poster check and download # --------------------------------------------------------------------------- class TestPosterCheck: """Test check_and_download_missing_posters logic.""" @pytest.mark.asyncio async def test_no_anime_directory_returns_empty_stats(self, folder_scan_service): """Missing anime_directory → empty stats.""" with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = None stats = await folder_scan_service.check_and_download_missing_posters() assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0} @pytest.mark.asyncio async def test_nonexistent_directory_returns_empty_stats( self, folder_scan_service, tmp_path ): """Non-existent anime_directory → empty stats.""" non_existent = tmp_path / "missing" with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(non_existent) stats = await folder_scan_service.check_and_download_missing_posters() assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0} @pytest.mark.asyncio async def test_no_series_folders_returns_empty_stats( self, folder_scan_service, tmp_path ): """Empty anime_directory → empty stats.""" with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) stats = await folder_scan_service.check_and_download_missing_posters() assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0} @pytest.mark.asyncio async def test_skips_folders_without_nfo(self, folder_scan_service, tmp_path): """Folders without tvshow.nfo are ignored.""" (tmp_path / "SomeShow").mkdir() with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) stats = await folder_scan_service.check_and_download_missing_posters() assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0} @pytest.mark.asyncio async def test_valid_poster_skipped(self, folder_scan_service, tmp_path): """Existing poster.jpg ≥ 1 KB is skipped.""" series_dir = tmp_path / "Attack on Titan (2013)" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "Attack on Titan2013" ) # Write a 2 KB poster (series_dir / "poster.jpg").write_bytes(b"x" * 2048) with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) stats = await folder_scan_service.check_and_download_missing_posters() assert stats["scanned"] == 1 assert stats["skipped"] == 1 assert stats["downloaded"] == 0 assert stats["errors"] == 0 @pytest.mark.asyncio async def test_missing_poster_downloaded(self, folder_scan_service, tmp_path): """Missing poster triggers download when thumb URL exists.""" series_dir = tmp_path / "Attack on Titan (2013)" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "" "Attack on Titan2013" 'https://example.com/poster.jpg' "" ) mock_downloader = AsyncMock() mock_downloader.download_poster = AsyncMock(return_value=True) mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader) mock_downloader.__aexit__ = AsyncMock(return_value=False) with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) mock_settings.nfo_download_poster = True with patch( "src.server.services.folder_scan_service.ImageDownloader", return_value=mock_downloader, ): stats = await folder_scan_service.check_and_download_missing_posters() assert stats["scanned"] == 1 assert stats["downloaded"] == 1 assert stats["skipped"] == 0 assert stats["errors"] == 0 @pytest.mark.asyncio async def test_no_thumb_url_skipped(self, folder_scan_service, tmp_path): """NFO without thumb URL → skipped.""" series_dir = tmp_path / "Attack on Titan (2013)" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "Attack on Titan2013" ) with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) mock_settings.nfo_download_poster = True stats = await folder_scan_service.check_and_download_missing_posters() assert stats["scanned"] == 1 assert stats["skipped"] == 1 assert stats["downloaded"] == 0 @pytest.mark.asyncio async def test_poster_download_disabled_by_setting( self, folder_scan_service, tmp_path ): """nfo_download_poster=False → skipped even with valid thumb URL.""" series_dir = tmp_path / "Attack on Titan (2013)" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "" "Attack on Titan2013" 'https://example.com/poster.jpg' "" ) with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) mock_settings.nfo_download_poster = False stats = await folder_scan_service.check_and_download_missing_posters() assert stats["scanned"] == 1 assert stats["skipped"] == 1 assert stats["downloaded"] == 0 @pytest.mark.asyncio async def test_download_failure_counts_as_error(self, folder_scan_service, tmp_path): """Failed download increments errors.""" series_dir = tmp_path / "Attack on Titan (2013)" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "" "Attack on Titan2013" 'https://example.com/poster.jpg' "" ) mock_downloader = AsyncMock() mock_downloader.download_poster = AsyncMock(return_value=False) mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader) mock_downloader.__aexit__ = AsyncMock(return_value=False) with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) mock_settings.nfo_download_poster = True with patch( "src.server.services.folder_scan_service.ImageDownloader", return_value=mock_downloader, ): stats = await folder_scan_service.check_and_download_missing_posters() assert stats["scanned"] == 1 assert stats["errors"] == 1 assert stats["downloaded"] == 0 @pytest.mark.asyncio async def test_download_exception_counts_as_error(self, folder_scan_service, tmp_path): """Exception during download increments errors.""" series_dir = tmp_path / "Attack on Titan (2013)" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "" "Attack on Titan2013" 'https://example.com/poster.jpg' "" ) mock_downloader = AsyncMock() mock_downloader.download_poster = AsyncMock(side_effect=RuntimeError("net")) mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader) mock_downloader.__aexit__ = AsyncMock(return_value=False) with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) mock_settings.nfo_download_poster = True with patch( "src.server.services.folder_scan_service.ImageDownloader", return_value=mock_downloader, ): stats = await folder_scan_service.check_and_download_missing_posters() assert stats["scanned"] == 1 assert stats["errors"] == 1 assert stats["downloaded"] == 0 @pytest.mark.asyncio async def test_too_small_poster_re_downloaded(self, folder_scan_service, tmp_path): """Poster < 1 KB is treated as missing and re-downloaded.""" series_dir = tmp_path / "Attack on Titan (2013)" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( "" "Attack on Titan2013" 'https://example.com/poster.jpg' "" ) # Write a tiny 100-byte poster (series_dir / "poster.jpg").write_bytes(b"x" * 100) mock_downloader = AsyncMock() mock_downloader.download_poster = AsyncMock(return_value=True) mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader) mock_downloader.__aexit__ = AsyncMock(return_value=False) with patch( "src.config.settings.settings" ) as mock_settings: mock_settings.anime_directory = str(tmp_path) mock_settings.nfo_download_poster = True with patch( "src.server.services.folder_scan_service.ImageDownloader", return_value=mock_downloader, ): stats = await folder_scan_service.check_and_download_missing_posters() assert stats["scanned"] == 1 assert stats["downloaded"] == 1 assert stats["skipped"] == 0 class TestExtractPosterUrl: """Test _extract_poster_url_from_nfo static method.""" def test_extract_poster_url_with_aspect(self, tmp_path): nfo = tmp_path / "tvshow.nfo" nfo.write_text( "" 'https://example.com/poster.jpg' "" ) url = FolderScanService._extract_poster_url_from_nfo(nfo) assert url == "https://example.com/poster.jpg" def test_extract_first_thumb_fallback(self, tmp_path): nfo = tmp_path / "tvshow.nfo" nfo.write_text( "" 'https://example.com/fallback.jpg' "" ) url = FolderScanService._extract_poster_url_from_nfo(nfo) assert url == "https://example.com/fallback.jpg" def test_no_thumb_returns_none(self, tmp_path): nfo = tmp_path / "tvshow.nfo" nfo.write_text("Test") url = FolderScanService._extract_poster_url_from_nfo(nfo) assert url is None def test_missing_file_returns_none(self, tmp_path): nfo = tmp_path / "tvshow.nfo" url = FolderScanService._extract_poster_url_from_nfo(nfo) assert url is None def test_malformed_xml_returns_none(self, tmp_path): nfo = tmp_path / "tvshow.nfo" nfo.write_text("not xml") url = FolderScanService._extract_poster_url_from_nfo(nfo) assert url is None # --------------------------------------------------------------------------- # Semaphores # --------------------------------------------------------------------------- class TestSemaphores: """Verify module-level semaphores exist and have correct initial value.""" def test_tmdb_semaphore_value(self): assert _TMDB_SEMAPHORE._value == 3 def test_poster_download_semaphore_value(self): assert _POSTER_DOWNLOAD_SEMAPHORE._value == 3 # --------------------------------------------------------------------------- # Full run_folder_scan integration # --------------------------------------------------------------------------- class TestRunFolderScanFull: """End-to-end tests for run_folder_scan with mocked sub-tasks.""" @pytest.mark.asyncio async def test_full_scan_happy_path(self, folder_scan_service, tmp_path): """All sub-tasks succeed.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( "src.server.services.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ) as mock_repair, patch( "src.server.services.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1}, ) as mock_rename, patch.object( folder_scan_service, "check_and_download_missing_posters", new_callable=AsyncMock, return_value={"scanned": 3, "downloaded": 2, "skipped": 1, "errors": 0}, ) as mock_poster: await folder_scan_service.run_folder_scan() mock_repair.assert_awaited_once_with(background_loader=None) mock_rename.assert_awaited_once() mock_poster.assert_awaited_once() @pytest.mark.asyncio async def test_full_scan_all_stats_zero(self, folder_scan_service, tmp_path): """Empty library → all stats zero.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( "src.server.services.folder_scan_service.perform_nfo_repair_scan", new_callable=AsyncMock, ), patch( "src.server.services.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, ), patch.object( folder_scan_service, "check_and_download_missing_posters", new_callable=AsyncMock, return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}, ): await folder_scan_service.run_folder_scan()