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

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