diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index c207367..34a5c56 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -10,6 +10,7 @@ import structlog from src.config.settings import settings from src.server.database.service import AnimeSeriesService from src.server.services.anime_service import sync_legacy_series_to_db +from src.server.services.setup_service import SetupService logger = structlog.get_logger(__name__) @@ -259,7 +260,8 @@ async def _load_series_into_memory(progress_service=None) -> None: async def _scan_folders_to_database(progress_service=None) -> int: """Scan anime folders and create AnimeSeries DB records. - This function runs during initial setup only. It: + This function runs during initial setup only. It delegates to + SetupService.run() which handles: 1. Iterates subdirectories of anime_directory 2. Extracts title/year from folder names (year via (YYYY) pattern) 3. Uses provider search to resolve key field when single match found @@ -271,9 +273,6 @@ async def _scan_folders_to_database(progress_service=None) -> int: Returns: int: Number of new series created """ - from src.server.database.connection import get_db_session - from src.server.utils.dependencies import get_series_app - logger.info("Scanning anime folders for new series...") if not settings.anime_directory or not os.path.isdir(settings.anime_directory): @@ -282,80 +281,12 @@ async def _scan_folders_to_database(progress_service=None) -> int: ) return 0 - created_count = 0 - skipped_existing = 0 - - try: - series_app = get_series_app() - - async with get_db_session() as db: - for folder in settings.anime_directory.iterdir(): - if not folder.is_dir(): - continue - - folder_name = folder.name - - # Skip if series already exists in DB - existing = await AnimeSeriesService.get_by_folder(db, folder_name) - if existing: - skipped_existing += 1 - continue - - # Extract year from folder name using (YYYY) pattern - year = None - match = re.search(r'\((\d{4})\)', folder_name) - if match: - year = int(match.group(1)) - - # Extract title by removing year suffix - title = re.sub(r'\s*\(\d{4}\)\s*$', '', folder_name).strip() - - # Try to resolve key via provider search - resolved_key = "" - if title: - try: - results = await series_app.search(title) - if len(results) == 1: - result_name = results[0].get('name', '').lower() - if result_name == title.lower(): - resolved_key = results[0].get('key', '') - except Exception as exc: - logger.warning( - "Provider search failed for folder", - folder=folder_name, - error=str(exc) - ) - - # Create AnimeSeries record - await AnimeSeriesService.create( - db=db, - key=resolved_key, - name=title, - site=ANIMEWORLD_URL, - folder=folder_name, - year=year, - ) - created_count += 1 - logger.debug( - "Created series from folder", - folder=folder_name, - title=title, - year=year, - key=resolved_key or "(unresolved)" - ) - - except Exception as exc: - logger.error( - "Folder scan failed", - error=str(exc), - exc_info=True - ) - return created_count + # Use SetupService to handle the scanning and creation + created_count = await SetupService.run() logger.info( "Folder scan complete", - created=created_count, - skipped_existing=skipped_existing + created=created_count ) return created_count diff --git a/src/server/services/setup_service.py b/src/server/services/setup_service.py new file mode 100644 index 0000000..0c09f30 --- /dev/null +++ b/src/server/services/setup_service.py @@ -0,0 +1,192 @@ +"""Setup service for first-time database initialization. + +This service runs during initial application setup to: +1. Scan anime folders in the data directory +2. Extract title and year from folder names +3. Create AnimeSeries records in the database +4. Resolve provider keys via search (if single match found) + +The run_once logic is handled by the caller (perform_initial_setup) +via _check_initial_scan_status, not by this service itself. +""" +import os +import re +from pathlib import Path +from typing import Optional + +import structlog + +from src.config.settings import settings +from src.server.database.connection import get_db_session +from src.server.database.service import AnimeSeriesService +from src.server.utils.dependencies import get_series_app + +logger = structlog.get_logger(__name__) + + +class SetupService: + """Service for setup operations during application initialization.""" + + @staticmethod + def _extract_year_from_folder_name(folder_name: str) -> Optional[int]: + """Extract year from folder name if present. + + Looks for year in format "(YYYY)" at the end of folder name. + + Args: + folder_name: The folder name to parse + + Returns: + Year as integer if found, None otherwise + """ + if not folder_name: + return None + + match = re.search(r'\((\d{4})\)', folder_name) + if match: + year = int(match.group(1)) + if 1900 <= year <= 2100: + return year + return None + + @staticmethod + def _extract_title_from_folder_name(folder_name: str) -> str: + """Extract title from folder name by removing year suffix. + + Args: + folder_name: The folder name to parse + + Returns: + Title with year suffix and surrounding whitespace removed + """ + return re.sub(r'\s*\(\d{4}\)\s*$', '', folder_name).strip() + + @staticmethod + async def _resolve_key_via_search(title: str) -> str: + """Resolve provider key by searching for the title. + + Args: + title: The title to search for + + Returns: + Provider key if exactly one match with same name found, + empty string otherwise + """ + if not title: + return "" + + try: + series_app = get_series_app() + results = await series_app.search(title) + + if len(results) == 1: + result_name = results[0].get('name', '').lower() + if result_name == title.lower(): + return results[0].get('key', '') + except Exception as e: + logger.warning( + "Provider search failed for folder", + title=title, + error=str(e) + ) + + return "" + + @classmethod + async def run(cls) -> int: + """Run the setup service. + + Scans anime folders, creates AnimeSeries records, and resolves + provider keys via search. Should only be called after checking + that initial scan hasn't been completed yet (via _check_initial_scan_status). + + Returns: + Number of new series created + """ + if not settings.anime_directory: + logger.info("Anime directory not configured, skipping setup") + return 0 + + anime_dir = Path(settings.anime_directory) + if not anime_dir.is_dir(): + logger.info( + "Anime directory does not exist, skipping setup: %s", + anime_dir + ) + return 0 + + logger.info("Running setup service...") + + created_count = 0 + skipped_existing = 0 + + try: + series_app = get_series_app() + + async with get_db_session() as db: + for folder in anime_dir.iterdir(): + if not folder.is_dir(): + continue + + folder_name = folder.name + + # Check if series already exists in DB + existing = await AnimeSeriesService.get_by_folder( + db, folder_name + ) + if existing: + skipped_existing += 1 + continue + + # Extract title and year from folder name + year = cls._extract_year_from_folder_name(folder_name) + title = cls._extract_title_from_folder_name(folder_name) + + if not title: + logger.warning( + "Could not extract title from folder: %s", + folder_name + ) + continue + + # Resolve key via provider search + resolved_key = await cls._resolve_key_via_search(title) + + # Create AnimeSeries record + await AnimeSeriesService.create( + db=db, + key=resolved_key, + name=title, + site="https://aniworld.to", + folder=folder_name, + year=year, + loading_status="completed", + episodes_loaded=True, + logo_loaded=False, + images_loaded=False, + ) + created_count += 1 + + logger.debug( + "Created series from folder", + folder=folder_name, + title=title, + year=year, + key=resolved_key or "(unresolved)" + ) + + logger.info( + "Setup complete", + created=created_count, + skipped_existing=skipped_existing + ) + + except Exception as e: + logger.error( + "Setup failed", + error=str(e), + exc_info=True + ) + return created_count + + return created_count \ No newline at end of file diff --git a/tests/unit/test_initialization_service.py b/tests/unit/test_initialization_service.py index e55cd8c..045e826 100644 --- a/tests/unit/test_initialization_service.py +++ b/tests/unit/test_initialization_service.py @@ -746,277 +746,179 @@ class TestScanFoldersToDatabase: @pytest.mark.asyncio async def test_scan_folders_extracts_year(self, tmp_path): - """Folder 'Attack on Titan (2013)' → title='Attack on Titan', year=2013.""" - # Create a real directory structure + """Folder 'Attack on Titan (2013)' → title='Attack on Titan', year=2013. + + This test verifies the integration between _scan_folders_to_database + and SetupService.run(). Since _scan_folders_to_database now delegates + to SetupService.run(), we mock SetupService.run() and verify it's called. + """ anime_dir = tmp_path / "anime" anime_dir.mkdir() folder_path = anime_dir / "Attack on Titan (2013)" folder_path.mkdir() - mock_series_app = AsyncMock() - mock_series_app.search.return_value = [] - - mock_db = AsyncMock() - mock_get_db = MagicMock() - mock_get_db.__aenter__.return_value = mock_db - mock_get_db.__aexit__.return_value = None - with patch( 'src.server.services.initialization_service.settings' ) as mock_settings, \ patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), \ - patch( - 'src.server.database.connection.get_db_session', - return_value=mock_get_db - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.get_by_folder', - new_callable=AsyncMock, return_value=None - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.create', - new_callable=AsyncMock - ) as mock_create: + 'src.server.services.initialization_service.SetupService.run', + new_callable=AsyncMock, return_value=1 + ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() assert result == 1 - mock_create.assert_called_once() - call_kwargs = mock_create.call_args.kwargs - assert call_kwargs['name'] == "Attack on Titan" - assert call_kwargs['year'] == 2013 + mock_setup_run.assert_called_once() @pytest.mark.asyncio async def test_scan_folders_no_year(self, tmp_path): - """Folder 'OnePiece' → title='OnePiece', year=None.""" + """Folder 'OnePiece' → title='OnePiece', year=None. + + This test verifies the integration between _scan_folders_to_database + and SetupService.run(). Since _scan_folders_to_database now delegates + to SetupService.run(), we mock SetupService.run() and verify it's called. + """ anime_dir = tmp_path / "anime" anime_dir.mkdir() folder_path = anime_dir / "OnePiece" folder_path.mkdir() - mock_series_app = AsyncMock() - mock_series_app.search.return_value = [] - - mock_db = AsyncMock() - mock_get_db = MagicMock() - mock_get_db.__aenter__.return_value = mock_db - mock_get_db.__aexit__.return_value = None - with patch( 'src.server.services.initialization_service.settings' ) as mock_settings, \ patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), \ - patch( - 'src.server.database.connection.get_db_session', - return_value=mock_get_db - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.get_by_folder', - new_callable=AsyncMock, return_value=None - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.create', - new_callable=AsyncMock - ) as mock_create: + 'src.server.services.initialization_service.SetupService.run', + new_callable=AsyncMock, return_value=1 + ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() assert result == 1 - call_kwargs = mock_create.call_args.kwargs - assert call_kwargs['name'] == "OnePiece" - assert call_kwargs['year'] is None + mock_setup_run.assert_called_once() @pytest.mark.asyncio async def test_scan_folders_single_match_uses_key(self, tmp_path): - """Search returns 1 match with same name → use its key.""" + """Search returns 1 match with same name → use its key. + + This test verifies the integration between _scan_folders_to_database + and SetupService.run(). Since _scan_folders_to_database now delegates + to SetupService.run(), we mock SetupService.run() and verify it's called. + """ anime_dir = tmp_path / "anime" anime_dir.mkdir() folder_path = anime_dir / "Attack on Titan (2013)" folder_path.mkdir() - mock_series_app = AsyncMock() - mock_series_app.search.return_value = [ - {'key': 'attack-on-titan', 'name': 'Attack on Titan'} - ] - - mock_db = AsyncMock() - mock_get_db = MagicMock() - mock_get_db.__aenter__.return_value = mock_db - mock_get_db.__aexit__.return_value = None - with patch( 'src.server.services.initialization_service.settings' ) as mock_settings, \ patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), \ - patch( - 'src.server.database.connection.get_db_session', - return_value=mock_get_db - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.get_by_folder', - new_callable=AsyncMock, return_value=None - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.create', - new_callable=AsyncMock - ) as mock_create: + 'src.server.services.initialization_service.SetupService.run', + new_callable=AsyncMock, return_value=1 + ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() assert result == 1 - call_kwargs = mock_create.call_args.kwargs - assert call_kwargs['key'] == 'attack-on-titan' + mock_setup_run.assert_called_once() @pytest.mark.asyncio async def test_scan_folders_no_match_leaves_key_empty(self, tmp_path): - """Search returns 0 results → key=''.""" + """Search returns 0 results → key=''. + + This test verifies the integration between _scan_folders_to_database + and SetupService.run(). Since _scan_folders_to_database now delegates + to SetupService.run(), we mock SetupService.run() and verify it's called. + """ anime_dir = tmp_path / "anime" anime_dir.mkdir() folder_path = anime_dir / "Unknown Series (2020)" folder_path.mkdir() - mock_series_app = AsyncMock() - mock_series_app.search.return_value = [] - - mock_db = AsyncMock() - mock_get_db = MagicMock() - mock_get_db.__aenter__.return_value = mock_db - mock_get_db.__aexit__.return_value = None - with patch( 'src.server.services.initialization_service.settings' ) as mock_settings, \ patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), \ - patch( - 'src.server.database.connection.get_db_session', - return_value=mock_get_db - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.get_by_folder', - new_callable=AsyncMock, return_value=None - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.create', - new_callable=AsyncMock - ) as mock_create: + 'src.server.services.initialization_service.SetupService.run', + new_callable=AsyncMock, return_value=1 + ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() assert result == 1 - call_kwargs = mock_create.call_args.kwargs - assert call_kwargs['key'] == '' + mock_setup_run.assert_called_once() @pytest.mark.asyncio async def test_scan_folders_multiple_matches_leaves_key_empty(self, tmp_path): - """Search returns >1 results → key=''.""" + """Search returns >1 results → key=''. + + This test verifies the integration between _scan_folders_to_database + and SetupService.run(). Since _scan_folders_to_database now delegates + to SetupService.run(), we mock SetupService.run() and verify it's called. + """ anime_dir = tmp_path / "anime" anime_dir.mkdir() folder_path = anime_dir / "Attack on Titan (2013)" folder_path.mkdir() - mock_series_app = AsyncMock() - mock_series_app.search.return_value = [ - {'key': 'attack-on-titan', 'name': 'Attack on Titan'}, - {'key': 'attack-on-titan-clone', 'name': 'Attack on Titan Clone'} - ] - - mock_db = AsyncMock() - mock_get_db = MagicMock() - mock_get_db.__aenter__.return_value = mock_db - mock_get_db.__aexit__.return_value = None - with patch( 'src.server.services.initialization_service.settings' ) as mock_settings, \ patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), \ - patch( - 'src.server.database.connection.get_db_session', - return_value=mock_get_db - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.get_by_folder', - new_callable=AsyncMock, return_value=None - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.create', - new_callable=AsyncMock - ) as mock_create: + 'src.server.services.initialization_service.SetupService.run', + new_callable=AsyncMock, return_value=1 + ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() assert result == 1 - call_kwargs = mock_create.call_args.kwargs - assert call_kwargs['key'] == '' + mock_setup_run.assert_called_once() @pytest.mark.asyncio async def test_scan_folders_skips_existing(self, tmp_path): - """Series with same folder already in DB → skip.""" + """Series with same folder already in DB → skip. + + This test verifies the integration between _scan_folders_to_database + and SetupService.run(). Since _scan_folders_to_database now delegates + to SetupService.run(), we mock SetupService.run() and verify it's called. + """ anime_dir = tmp_path / "anime" anime_dir.mkdir() folder_path = anime_dir / "Attack on Titan (2013)" folder_path.mkdir() - mock_series_app = AsyncMock() - - mock_db = AsyncMock() - mock_get_db = MagicMock() - mock_get_db.__aenter__.return_value = mock_db - mock_get_db.__aexit__.return_value = None - with patch( 'src.server.services.initialization_service.settings' ) as mock_settings, \ patch( - 'src.server.utils.dependencies.get_series_app', - return_value=mock_series_app - ), \ - patch( - 'src.server.database.connection.get_db_session', - return_value=mock_get_db - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.get_by_folder', - new_callable=AsyncMock, return_value=MagicMock() # existing series - ), \ - patch( - 'src.server.services.initialization_service.AnimeSeriesService.create', - new_callable=AsyncMock - ) as mock_create: + 'src.server.services.initialization_service.SetupService.run', + new_callable=AsyncMock, return_value=0 + ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() assert result == 0 - mock_create.assert_not_called() + mock_setup_run.assert_called_once() @pytest.mark.asyncio async def test_scan_folders_empty_anime_directory(self): """No anime directory configured → return 0.""" with patch( 'src.server.services.initialization_service.settings' - ) as mock_settings: + ) as mock_settings, \ + patch( + 'src.server.services.initialization_service.SetupService.run', + new_callable=AsyncMock, return_value=0 + ) as mock_setup_run: mock_settings.anime_directory = None result = await _scan_folders_to_database() assert result == 0 + mock_setup_run.assert_not_called() diff --git a/tests/unit/test_setup_service.py b/tests/unit/test_setup_service.py new file mode 100644 index 0000000..b83f09e --- /dev/null +++ b/tests/unit/test_setup_service.py @@ -0,0 +1,392 @@ +"""Tests for SetupService.""" +import re +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.server.services.setup_service import SetupService + + +class TestExtractYearFromFolderName: + """Test _extract_year_from_folder_name method.""" + + def test_extracts_year_in_parentheses(self): + """Folder name with (YYYY) at end → extracts year.""" + assert SetupService._extract_year_from_folder_name("Attack on Titan (2013)") == 2013 + assert SetupService._extract_year_from_folder_name("OnePiece (1999)") == 1999 + assert SetupService._extract_year_from_folder_name("Naruto (2002)") == 2002 + + def test_returns_none_for_no_year(self): + """Folder name without year → returns None.""" + assert SetupService._extract_year_from_folder_name("OnePiece") is None + assert SetupService._extract_year_from_folder_name("MyHeroAcademia") is None + + def test_returns_none_for_empty_string(self): + """Empty string → returns None.""" + assert SetupService._extract_year_from_folder_name("") is None + + def test_returns_none_for_invalid_year(self): + """Year outside valid range → returns None.""" + assert SetupService._extract_year_from_folder_name("Test (1800)") is None + assert SetupService._extract_year_from_folder_name("Test (2150)") is None + + def test_year_must_be_four_digits(self): + """Only 4-digit years are matched.""" + assert SetupService._extract_year_from_folder_name("Test (23)") is None + assert SetupService._extract_year_from_folder_name("Test (20231)") is None + + +class TestExtractTitleFromFolderName: + """Test _extract_title_from_folder_name method.""" + + def test_removes_year_suffix(self): + """Folder name with (YYYY) → title without year.""" + assert SetupService._extract_title_from_folder_name("Attack on Titan (2013)") == "Attack on Titan" + assert SetupService._extract_title_from_folder_name("OnePiece (1999)") == "OnePiece" + + def test_preserves_title_without_year(self): + """Folder name without year → unchanged title.""" + assert SetupService._extract_title_from_folder_name("OnePiece") == "OnePiece" + + def test_strips_whitespace(self): + """Year suffix with whitespace → properly stripped.""" + assert SetupService._extract_title_from_folder_name("Test (2020) ") == "Test" + assert SetupService._extract_title_from_folder_name(" Test (2020)") == "Test" + + def test_returns_empty_for_empty_string(self): + """Empty string → returns empty string.""" + assert SetupService._extract_title_from_folder_name("") == "" + + +class TestResolveKeyViaSearch: + """Test _resolve_key_via_search method.""" + + @pytest.mark.asyncio + async def test_returns_key_when_single_exact_match(self): + """Search returns 1 result with same name → returns key.""" + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [ + {'key': 'attack-on-titan', 'name': 'Attack on Titan'} + ] + + with patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ): + result = await SetupService._resolve_key_via_search("Attack on Titan") + + assert result == 'attack-on-titan' + + @pytest.mark.asyncio + async def test_returns_empty_when_no_results(self): + """Search returns 0 results → returns empty string.""" + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [] + + with patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ): + result = await SetupService._resolve_key_via_search("Unknown Series") + + assert result == '' + + @pytest.mark.asyncio + async def test_returns_empty_when_multiple_results(self): + """Search returns >1 results → returns empty string.""" + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [ + {'key': 'attack-on-titan', 'name': 'Attack on Titan'}, + {'key': 'attack-on-titan-ova', 'name': 'Attack on Titan OVA'} + ] + + with patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ): + result = await SetupService._resolve_key_via_search("Attack on Titan") + + assert result == '' + + @pytest.mark.asyncio + async def test_returns_empty_when_name_mismatch(self): + """Search returns 1 result but name differs (case-insensitive) → returns empty string.""" + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [ + {'key': 'attack-on-titan', 'name': 'Attack on Titan'} + ] + + with patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ): + result = await SetupService._resolve_key_via_search("attack on titan") # lowercase + + # Should still match since comparison is case-insensitive + assert result == 'attack-on-titan' + + @pytest.mark.asyncio + async def test_returns_empty_when_empty_title(self): + """Empty title → returns empty string without calling search.""" + result = await SetupService._resolve_key_via_search("") + + assert result == '' + + @pytest.mark.asyncio + async def test_handles_search_exception(self): + """Search raises exception → returns empty string.""" + mock_series_app = AsyncMock() + mock_series_app.search.side_effect = Exception("Network error") + + with patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ): + result = await SetupService._resolve_key_via_search("Test") + + assert result == '' + + +class TestSetupServiceRun: + """Test SetupService.run method.""" + + @pytest.mark.asyncio + async def test_creates_series_for_new_folders(self, tmp_path): + """Folders without DB entries → creates AnimeSeries records.""" + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + (anime_dir / "Attack on Titan (2013)").mkdir() + (anime_dir / "OnePiece").mkdir() + + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [] + + mock_db = AsyncMock() + mock_get_db = MagicMock() + mock_get_db.__aenter__.return_value = mock_db + mock_get_db.__aexit__.return_value = None + + with patch( + 'src.server.services.setup_service.settings' + ) as mock_settings, \ + patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ), \ + patch( + 'src.server.services.setup_service.get_db_session', + return_value=mock_get_db + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', + new_callable=AsyncMock, return_value=None + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.create', + new_callable=AsyncMock + ) as mock_create: + mock_settings.anime_directory = str(anime_dir) + + result = await SetupService.run() + + assert result == 2 + assert mock_create.call_count == 2 + + @pytest.mark.asyncio + async def test_skips_existing_folders(self, tmp_path): + """Folder already in DB → skipped.""" + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + (anime_dir / "Attack on Titan (2013)").mkdir() + + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [] + + mock_db = AsyncMock() + mock_get_db = MagicMock() + mock_get_db.__aenter__.return_value = mock_db + mock_get_db.__aexit__.return_value = None + + with patch( + 'src.server.services.setup_service.settings' + ) as mock_settings, \ + patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ), \ + patch( + 'src.server.services.setup_service.get_db_session', + return_value=mock_get_db + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', + new_callable=AsyncMock, return_value=MagicMock() # existing + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.create', + new_callable=AsyncMock + ) as mock_create: + mock_settings.anime_directory = str(anime_dir) + + result = await SetupService.run() + + assert result == 0 + mock_create.assert_not_called() + + @pytest.mark.asyncio + async def test_resolves_key_for_single_match(self, tmp_path): + """Single search match with same name → uses that key.""" + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + (anime_dir / "Attack on Titan (2013)").mkdir() + + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [ + {'key': 'attack-on-titan', 'name': 'Attack on Titan'} + ] + + mock_db = AsyncMock() + mock_get_db = MagicMock() + mock_get_db.__aenter__.return_value = mock_db + mock_get_db.__aexit__.return_value = None + + with patch( + 'src.server.services.setup_service.settings' + ) as mock_settings, \ + patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ), \ + patch( + 'src.server.services.setup_service.get_db_session', + return_value=mock_get_db + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', + new_callable=AsyncMock, return_value=None + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.create', + new_callable=AsyncMock + ) as mock_create: + mock_settings.anime_directory = str(anime_dir) + + await SetupService.run() + + # Verify create was called with resolved key + call_kwargs = mock_create.call_args.kwargs + assert call_kwargs['key'] == 'attack-on-titan' + assert call_kwargs['name'] == 'Attack on Titan' + assert call_kwargs['year'] == 2013 + + @pytest.mark.asyncio + async def test_empty_key_for_no_match(self, tmp_path): + """No search match → empty key.""" + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + (anime_dir / "Unknown Series (2020)").mkdir() + + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [] + + mock_db = AsyncMock() + mock_get_db = MagicMock() + mock_get_db.__aenter__.return_value = mock_db + mock_get_db.__aexit__.return_value = None + + with patch( + 'src.server.services.setup_service.settings' + ) as mock_settings, \ + patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ), \ + patch( + 'src.server.services.setup_service.get_db_session', + return_value=mock_get_db + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', + new_callable=AsyncMock, return_value=None + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.create', + new_callable=AsyncMock + ) as mock_create: + mock_settings.anime_directory = str(anime_dir) + + await SetupService.run() + + call_kwargs = mock_create.call_args.kwargs + assert call_kwargs['key'] == '' + assert call_kwargs['name'] == 'Unknown Series' + assert call_kwargs['year'] == 2020 + + @pytest.mark.asyncio + async def test_returns_zero_when_directory_not_configured(self): + """anime_directory not configured → returns 0.""" + with patch( + 'src.server.services.setup_service.settings' + ) as mock_settings: + mock_settings.anime_directory = "" + + result = await SetupService.run() + + assert result == 0 + + @pytest.mark.asyncio + async def test_returns_zero_when_directory_not_exist(self, tmp_path): + """anime_directory doesn't exist → returns 0.""" + fake_dir = tmp_path / "nonexistent" + + with patch( + 'src.server.services.setup_service.settings' + ) as mock_settings: + mock_settings.anime_directory = str(fake_dir) + + result = await SetupService.run() + + assert result == 0 + + @pytest.mark.asyncio + async def test_skips_files_only_processes_directories(self, tmp_path): + """Non-directory items in anime folder → skipped.""" + anime_dir = tmp_path / "anime" + anime_dir.mkdir() + (anime_dir / "Valid Series (2020)").mkdir() + (anime_dir / "random_file.txt").touch() + + mock_series_app = AsyncMock() + mock_series_app.search.return_value = [] + + mock_db = AsyncMock() + mock_get_db = MagicMock() + mock_get_db.__aenter__.return_value = mock_db + mock_get_db.__aexit__.return_value = None + + with patch( + 'src.server.services.setup_service.settings' + ) as mock_settings, \ + patch( + 'src.server.services.setup_service.get_series_app', + return_value=mock_series_app + ), \ + patch( + 'src.server.services.setup_service.get_db_session', + return_value=mock_get_db + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.get_by_folder', + new_callable=AsyncMock, return_value=None + ), \ + patch( + 'src.server.services.setup_service.AnimeSeriesService.create', + new_callable=AsyncMock + ) as mock_create: + mock_settings.anime_directory = str(anime_dir) + + result = await SetupService.run() + + assert result == 1 + mock_create.assert_called_once()