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:
2026-06-05 19:54:45 +02:00
parent 2c47713339
commit 5c2be3f7c4
4 changed files with 656 additions and 239 deletions

View File

@@ -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

View 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

View File

@@ -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()

View 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()