When SetupService cannot auto-resolve a provider key for an anime folder,
the folder is now tracked in the new 'unresolved_folders' table instead of
being silently skipped. Users can then resolve these via the new API:
- GET /api/setup/unresolved - list unresolved folders with search suggestions
- POST /api/setup/unresolved/{folder}/resolve - provide key to resolve folder
The SetupService.run() now:
- Tracks unresolved folders instead of skipping them
- Re-creates AnimeSeries for previously unresolved folders that are now resolved
- Includes unresolved count in logs
New files:
- src/server/api/setup_endpoints.py - API endpoints for unresolved management
- tests/unit/test_unresolved_folder_service.py - service and model tests
Modified:
- src/server/database/models.py - add UnresolvedFolder model
- src/server/database/service.py - add UnresolvedFolderService
- src/server/services/setup_service.py - track unresolved folders
- src/server/fastapi_app.py - include setup router
571 lines
21 KiB
Python
571 lines
21 KiB
Python
"""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 = [
|
|
{'title': 'Attack on Titan', 'link': '/anime/stream/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 = [
|
|
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'},
|
|
{'title': 'Attack on Titan OVA', 'link': '/anime/stream/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 = [
|
|
{'title': 'Attack on Titan', 'link': '/anime/stream/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 and single search match → creates AnimeSeries records.
|
|
|
|
Note: This test verifies the logic flow when search returns a single match.
|
|
The actual search call goes through SeriesApp which uses run_in_executor,
|
|
so we test the flow with a resolved key being passed through.
|
|
"""
|
|
anime_dir = tmp_path / "anime"
|
|
anime_dir.mkdir()
|
|
(anime_dir / "Attack on Titan (2013)").mkdir()
|
|
|
|
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_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.UnresolvedFolderService.get_by_folder_name',
|
|
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)
|
|
|
|
# Directly test the flow by patching _resolve_key_via_search
|
|
# to return a key (simulating successful search)
|
|
with patch.object(
|
|
SetupService, '_resolve_key_via_search',
|
|
new_callable=AsyncMock, return_value='attack-on-titan'
|
|
):
|
|
result = await SetupService.run()
|
|
|
|
assert result == 1
|
|
mock_create.assert_called_once()
|
|
call_kwargs = mock_create.call_args.kwargs
|
|
assert call_kwargs['key'] == 'attack-on-titan'
|
|
|
|
@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.
|
|
|
|
This tests that when _resolve_key_via_search returns a key,
|
|
the series is created with that key.
|
|
"""
|
|
anime_dir = tmp_path / "anime"
|
|
anime_dir.mkdir()
|
|
(anime_dir / "Attack on Titan (2013)").mkdir()
|
|
|
|
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_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.UnresolvedFolderService.get_by_folder_name',
|
|
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)
|
|
|
|
# Simulate successful search returning a key
|
|
with patch.object(
|
|
SetupService, '_resolve_key_via_search',
|
|
new_callable=AsyncMock, return_value='attack-on-titan'
|
|
):
|
|
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_tracks_unresolved_when_no_match(self, tmp_path):
|
|
"""No search match → tracks folder as unresolved, doesn't create series."""
|
|
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.UnresolvedFolderService.get_by_folder_name',
|
|
new_callable=AsyncMock, return_value=None
|
|
), \
|
|
patch(
|
|
'src.server.services.setup_service.UnresolvedFolderService.create',
|
|
new_callable=AsyncMock
|
|
) as mock_create_unresolved:
|
|
mock_settings.anime_directory = str(anime_dir)
|
|
|
|
result = await SetupService.run()
|
|
|
|
# Should return 0 since no series was created
|
|
assert result == 0
|
|
# Should track as unresolved instead of creating series
|
|
mock_create_unresolved.assert_called_once()
|
|
call_kwargs = mock_create_unresolved.call_args.kwargs
|
|
assert call_kwargs['folder_name'] == 'Unknown Series (2020)'
|
|
assert call_kwargs['title'] == '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.UnresolvedFolderService.get_by_folder_name',
|
|
new_callable=AsyncMock, return_value=None
|
|
), \
|
|
patch(
|
|
'src.server.services.setup_service.UnresolvedFolderService.create',
|
|
new_callable=AsyncMock
|
|
) as mock_create_unresolved:
|
|
mock_settings.anime_directory = str(anime_dir)
|
|
|
|
result = await SetupService.run()
|
|
|
|
# Empty search results → folder tracked as unresolved, not created
|
|
assert result == 0
|
|
mock_create_unresolved.assert_called_once()
|
|
|
|
|
|
class TestCheckNfoFile:
|
|
"""Test _check_nfo_file method."""
|
|
|
|
def test_returns_true_when_tvshow_nfo_exists(self, tmp_path):
|
|
"""tvshow.nfo exists → returns (True, path, created, updated)."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
nfo_file = folder / "tvshow.nfo"
|
|
nfo_file.touch()
|
|
|
|
has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder)
|
|
|
|
assert has_nfo is True
|
|
assert nfo_path == str(nfo_file)
|
|
assert created is not None
|
|
assert updated is not None
|
|
|
|
def test_returns_false_when_no_nfo(self, tmp_path):
|
|
"""No tvshow.nfo → returns (False, None, None, None)."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
|
|
has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder)
|
|
|
|
assert has_nfo is False
|
|
assert nfo_path is None
|
|
assert created is None
|
|
assert updated is None
|
|
|
|
def test_returns_false_when_nfo_is_directory(self, tmp_path):
|
|
"""tvshow.nfo exists but is a directory → returns False."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "tvshow.nfo").mkdir()
|
|
|
|
has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder)
|
|
|
|
assert has_nfo is False
|
|
|
|
|
|
class TestCheckLogoFile:
|
|
"""Test _check_logo_file method."""
|
|
|
|
def test_returns_true_when_logo_png_exists(self, tmp_path):
|
|
"""logo.png exists → returns True."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "logo.png").touch()
|
|
|
|
assert SetupService._check_logo_file(folder) is True
|
|
|
|
def test_returns_false_when_no_logo(self, tmp_path):
|
|
"""No logo.png → returns False."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
|
|
assert SetupService._check_logo_file(folder) is False
|
|
|
|
def test_returns_false_for_other_files(self, tmp_path):
|
|
"""Files like logo.jpg or logo.gif → returns False."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "logo.jpg").touch()
|
|
(folder / "logo.gif").touch()
|
|
|
|
assert SetupService._check_logo_file(folder) is False
|
|
|
|
|
|
class TestCheckImageFiles:
|
|
"""Test _check_image_files method."""
|
|
|
|
def test_returns_true_for_poster_jpg(self, tmp_path):
|
|
"""poster.jpg exists → returns True."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "poster.jpg").touch()
|
|
|
|
assert SetupService._check_image_files(folder) is True
|
|
|
|
def test_returns_true_for_poster_jpeg(self, tmp_path):
|
|
"""poster.jpeg exists → returns True."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "poster.jpeg").touch()
|
|
|
|
assert SetupService._check_image_files(folder) is True
|
|
|
|
def test_returns_true_for_poster_png(self, tmp_path):
|
|
"""poster.png exists → returns True."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "poster.png").touch()
|
|
|
|
assert SetupService._check_image_files(folder) is True
|
|
|
|
def test_returns_true_for_fanart_jpg(self, tmp_path):
|
|
"""fanart.jpg exists → returns True."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "fanart.jpg").touch()
|
|
|
|
assert SetupService._check_image_files(folder) is True
|
|
|
|
def test_returns_false_when_no_images(self, tmp_path):
|
|
"""No poster or fanart images → returns False."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "episode_01.mp4").touch()
|
|
|
|
assert SetupService._check_image_files(folder) is False
|
|
|
|
def test_returns_false_for_unrelated_files(self, tmp_path):
|
|
"""Files not matching poster/fanart pattern → returns False."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "banner.png").touch()
|
|
(folder / "thumbnail.jpg").touch()
|
|
|
|
assert SetupService._check_image_files(folder) is False
|
|
|
|
|
|
class TestGetSeriesProperties:
|
|
"""Test _get_series_properties method."""
|
|
|
|
def test_returns_all_properties_from_filesystem(self, tmp_path):
|
|
"""Folder with tvshow.nfo, logo.png, poster.jpg → returns correct props."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
(folder / "tvshow.nfo").touch()
|
|
(folder / "logo.png").touch()
|
|
(folder / "poster.jpg").touch()
|
|
|
|
props = SetupService._get_series_properties(folder)
|
|
|
|
assert props.has_nfo is True
|
|
assert props.nfo_path is not None
|
|
assert props.logo_loaded is True
|
|
assert props.images_loaded is True
|
|
|
|
def test_returns_defaults_when_no_files(self, tmp_path):
|
|
"""Empty folder → returns all False/None."""
|
|
folder = tmp_path / "Series"
|
|
folder.mkdir()
|
|
|
|
props = SetupService._get_series_properties(folder)
|
|
|
|
assert props.has_nfo is False
|
|
assert props.nfo_path is None
|
|
assert props.logo_loaded is False
|
|
assert props.images_loaded is False
|