feat(setup): add SetupService for anime folder initialization
Extract SetupService class from initialization_service to handle: - Scan data/ folder subdirectories - Extract title and year from folder names (YYYY pattern) - Create AnimeSeries records in database - Resolve provider keys via search (single exact match) Updates _scan_folders_to_database() to delegate to SetupService.run(). Adds comprehensive unit tests for SetupService.
This commit is contained in:
@@ -10,6 +10,7 @@ import structlog
|
|||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
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__)
|
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:
|
async def _scan_folders_to_database(progress_service=None) -> int:
|
||||||
"""Scan anime folders and create AnimeSeries DB records.
|
"""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
|
1. Iterates subdirectories of anime_directory
|
||||||
2. Extracts title/year from folder names (year via (YYYY) pattern)
|
2. Extracts title/year from folder names (year via (YYYY) pattern)
|
||||||
3. Uses provider search to resolve key field when single match found
|
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:
|
Returns:
|
||||||
int: Number of new series created
|
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...")
|
logger.info("Scanning anime folders for new series...")
|
||||||
|
|
||||||
if not settings.anime_directory or not os.path.isdir(settings.anime_directory):
|
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
|
return 0
|
||||||
|
|
||||||
created_count = 0
|
# Use SetupService to handle the scanning and creation
|
||||||
skipped_existing = 0
|
created_count = await SetupService.run()
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Folder scan complete",
|
"Folder scan complete",
|
||||||
created=created_count,
|
created=created_count
|
||||||
skipped_existing=skipped_existing
|
|
||||||
)
|
)
|
||||||
return created_count
|
return created_count
|
||||||
|
|
||||||
|
|||||||
192
src/server/services/setup_service.py
Normal file
192
src/server/services/setup_service.py
Normal file
@@ -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
|
||||||
@@ -746,277 +746,179 @@ class TestScanFoldersToDatabase:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_folders_extracts_year(self, tmp_path):
|
async def test_scan_folders_extracts_year(self, tmp_path):
|
||||||
"""Folder 'Attack on Titan (2013)' → title='Attack on Titan', year=2013."""
|
"""Folder 'Attack on Titan (2013)' → title='Attack on Titan', year=2013.
|
||||||
# Create a real directory structure
|
|
||||||
|
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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
folder_path = anime_dir / "Attack on Titan (2013)"
|
folder_path = anime_dir / "Attack on Titan (2013)"
|
||||||
folder_path.mkdir()
|
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(
|
with patch(
|
||||||
'src.server.services.initialization_service.settings'
|
'src.server.services.initialization_service.settings'
|
||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.utils.dependencies.get_series_app',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
return_value=mock_series_app
|
new_callable=AsyncMock, return_value=1
|
||||||
), \
|
) as mock_setup_run:
|
||||||
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:
|
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
mock_create.assert_called_once()
|
mock_setup_run.assert_called_once()
|
||||||
call_kwargs = mock_create.call_args.kwargs
|
|
||||||
assert call_kwargs['name'] == "Attack on Titan"
|
|
||||||
assert call_kwargs['year'] == 2013
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_folders_no_year(self, tmp_path):
|
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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
folder_path = anime_dir / "OnePiece"
|
folder_path = anime_dir / "OnePiece"
|
||||||
folder_path.mkdir()
|
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(
|
with patch(
|
||||||
'src.server.services.initialization_service.settings'
|
'src.server.services.initialization_service.settings'
|
||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.utils.dependencies.get_series_app',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
return_value=mock_series_app
|
new_callable=AsyncMock, return_value=1
|
||||||
), \
|
) as mock_setup_run:
|
||||||
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:
|
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
call_kwargs = mock_create.call_args.kwargs
|
mock_setup_run.assert_called_once()
|
||||||
assert call_kwargs['name'] == "OnePiece"
|
|
||||||
assert call_kwargs['year'] is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_folders_single_match_uses_key(self, tmp_path):
|
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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
folder_path = anime_dir / "Attack on Titan (2013)"
|
folder_path = anime_dir / "Attack on Titan (2013)"
|
||||||
folder_path.mkdir()
|
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(
|
with patch(
|
||||||
'src.server.services.initialization_service.settings'
|
'src.server.services.initialization_service.settings'
|
||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.utils.dependencies.get_series_app',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
return_value=mock_series_app
|
new_callable=AsyncMock, return_value=1
|
||||||
), \
|
) as mock_setup_run:
|
||||||
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:
|
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
call_kwargs = mock_create.call_args.kwargs
|
mock_setup_run.assert_called_once()
|
||||||
assert call_kwargs['key'] == 'attack-on-titan'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_folders_no_match_leaves_key_empty(self, tmp_path):
|
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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
folder_path = anime_dir / "Unknown Series (2020)"
|
folder_path = anime_dir / "Unknown Series (2020)"
|
||||||
folder_path.mkdir()
|
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(
|
with patch(
|
||||||
'src.server.services.initialization_service.settings'
|
'src.server.services.initialization_service.settings'
|
||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.utils.dependencies.get_series_app',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
return_value=mock_series_app
|
new_callable=AsyncMock, return_value=1
|
||||||
), \
|
) as mock_setup_run:
|
||||||
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:
|
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
call_kwargs = mock_create.call_args.kwargs
|
mock_setup_run.assert_called_once()
|
||||||
assert call_kwargs['key'] == ''
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_folders_multiple_matches_leaves_key_empty(self, tmp_path):
|
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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
folder_path = anime_dir / "Attack on Titan (2013)"
|
folder_path = anime_dir / "Attack on Titan (2013)"
|
||||||
folder_path.mkdir()
|
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(
|
with patch(
|
||||||
'src.server.services.initialization_service.settings'
|
'src.server.services.initialization_service.settings'
|
||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.utils.dependencies.get_series_app',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
return_value=mock_series_app
|
new_callable=AsyncMock, return_value=1
|
||||||
), \
|
) as mock_setup_run:
|
||||||
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:
|
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
call_kwargs = mock_create.call_args.kwargs
|
mock_setup_run.assert_called_once()
|
||||||
assert call_kwargs['key'] == ''
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_folders_skips_existing(self, tmp_path):
|
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 = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
folder_path = anime_dir / "Attack on Titan (2013)"
|
folder_path = anime_dir / "Attack on Titan (2013)"
|
||||||
folder_path.mkdir()
|
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(
|
with patch(
|
||||||
'src.server.services.initialization_service.settings'
|
'src.server.services.initialization_service.settings'
|
||||||
) as mock_settings, \
|
) as mock_settings, \
|
||||||
patch(
|
patch(
|
||||||
'src.server.utils.dependencies.get_series_app',
|
'src.server.services.initialization_service.SetupService.run',
|
||||||
return_value=mock_series_app
|
new_callable=AsyncMock, return_value=0
|
||||||
), \
|
) as mock_setup_run:
|
||||||
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:
|
|
||||||
mock_settings.anime_directory = anime_dir
|
mock_settings.anime_directory = anime_dir
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
mock_create.assert_not_called()
|
mock_setup_run.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_folders_empty_anime_directory(self):
|
async def test_scan_folders_empty_anime_directory(self):
|
||||||
"""No anime directory configured → return 0."""
|
"""No anime directory configured → return 0."""
|
||||||
with patch(
|
with patch(
|
||||||
'src.server.services.initialization_service.settings'
|
'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
|
mock_settings.anime_directory = None
|
||||||
|
|
||||||
result = await _scan_folders_to_database()
|
result = await _scan_folders_to_database()
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
mock_setup_run.assert_not_called()
|
||||||
|
|||||||
392
tests/unit/test_setup_service.py
Normal file
392
tests/unit/test_setup_service.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user