refactor: simplify NFO handling, remove legacy services

- Drop nfo_factory, nfo_repair_service, nfo_service, series_manager_service
- Delete key_resolution_service, consolidate into folder_rename_service
- Remove bulk of NFO-related tests (coverage via integration tests)
- Streamline SeriesApp, background_loader, initialization services
- Add folder_rename_service to scheduler

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-06-04 18:54:31 +02:00
parent 97caaf0d18
commit 21af502184
53 changed files with 175 additions and 16588 deletions

View File

@@ -1,314 +0,0 @@
"""Integration test: add an anime and verify NFO contains required information.
This test adds 'Sacrificial Princess And The King Of Beasts' and verifies
that the generated tvshow.nfo contains all required tags including plot,
outline, title, year, etc.
"""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from lxml import etree
from src.core.services.nfo_service import NFOService
# ---------------------------------------------------------------------------
# Mock TMDB data for "Sacrificial Princess And The King Of Beasts"
# ---------------------------------------------------------------------------
MOCK_TMDB_DATA = {
"id": 222093,
"name": "Sacrificial Princess and the King of Beasts",
"original_name": "贄姫と獣の王",
"overview": (
"A girl is offered as a sacrifice to a beastly king, "
"but instead of being eaten, she becomes his bride."
),
"tagline": "A tale of love between a sacrifice and a beast king.",
"first_air_date": "2023-04-20",
"vote_average": 7.5,
"vote_count": 150,
"status": "Ended",
"episode_run_time": [24],
"genres": [
{"id": 16, "name": "Animation"},
{"id": 10749, "name": "Romance"},
],
"networks": [{"id": 1, "name": "TBS"}],
"origin_country": ["JP"],
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
"credits": {
"cast": [
{
"id": 1,
"name": "Test Actor",
"character": "Sariphi",
"profile_path": "/actor.jpg",
}
]
},
"images": {"logos": [{"file_path": "/logo.png"}]},
"seasons": [{"season_number": 1, "name": "Season 1"}],
}
MOCK_CONTENT_RATINGS = {
"results": [
{"iso_3166_1": "DE", "rating": "12"},
{"iso_3166_1": "US", "rating": "TV-14"},
]
}
# ---------------------------------------------------------------------------
# Required XML tags that must exist and be non-empty after creation
# ---------------------------------------------------------------------------
REQUIRED_SINGLE_TAGS = [
"title",
"originaltitle",
"sorttitle",
"year",
"plot",
"outline",
"runtime",
"premiered",
"status",
"tmdbid",
"imdbid",
"tvdbid",
"dateadded",
"watched",
"mpaa",
"tagline",
]
REQUIRED_MULTI_TAGS = [
"genre",
"studio",
"country",
]
@pytest.fixture
def anime_dir(tmp_path: Path) -> Path:
"""Temporary anime root directory."""
d = tmp_path / "anime"
d.mkdir()
return d
@pytest.fixture
def nfo_service(anime_dir: Path) -> NFOService:
"""NFOService pointing at the temp directory."""
return NFOService(
tmdb_api_key="test_api_key",
anime_directory=str(anime_dir),
image_size="w500",
auto_create=True,
)
class TestAddAnimeNFOContent:
"""Test that adding an anime produces an NFO with required information."""
@pytest.mark.asyncio
async def test_add_anime_nfo_contains_required_tags(
self,
nfo_service: NFOService,
anime_dir: Path,
) -> None:
"""Add 'Sacrificial Princess And The King Of Beasts' and verify NFO.
Steps:
1. Create the series folder on disk.
2. Mock TMDB API responses.
3. Call create_tvshow_nfo to generate the NFO.
4. Parse the resulting XML and assert every required tag is present
and non-empty.
"""
series_key = "sacrificial-princess-and-the-king-of-beasts"
series_name = "Sacrificial Princess And The King Of Beasts"
series_folder = f"{series_name} (2023)"
# Step 1: Create series folder
series_path = anime_dir / series_folder
series_path.mkdir()
# Step 2: Mock TMDB API calls
with patch.object(
nfo_service.tmdb_client,
"search_tv_show",
new_callable=AsyncMock,
) as mock_search, patch.object(
nfo_service.tmdb_client,
"get_tv_show_details",
new_callable=AsyncMock,
) as mock_details, patch.object(
nfo_service.tmdb_client,
"get_tv_show_content_ratings",
new_callable=AsyncMock,
) as mock_ratings, patch.object(
nfo_service.image_downloader,
"download_all_media",
new_callable=AsyncMock,
) as mock_download:
mock_search.return_value = {
"results": [
{
"id": 222093,
"name": series_name,
"first_air_date": "2023-04-20",
"overview": (
"A girl is offered as a sacrifice to a beastly king..."
),
}
]
}
mock_details.return_value = MOCK_TMDB_DATA
mock_ratings.return_value = MOCK_CONTENT_RATINGS
mock_download.return_value = {
"poster": True,
"logo": True,
"fanart": True,
}
# Step 3: Create NFO
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=series_name,
serie_folder=series_folder,
year=2023,
download_poster=True,
download_logo=True,
download_fanart=True,
)
# Verify NFO was created
assert nfo_path.exists(), f"NFO file not created at {nfo_path}"
assert nfo_path.name == "tvshow.nfo"
# Step 4: Parse NFO XML and verify required tags
nfo_content = nfo_path.read_text(encoding="utf-8")
root = etree.fromstring(nfo_content.encode("utf-8"))
missing: list[str] = []
for tag in REQUIRED_SINGLE_TAGS:
elem = root.find(f".//{tag}")
if elem is None or not (elem.text or "").strip():
missing.append(tag)
for tag in REQUIRED_MULTI_TAGS:
elems = root.findall(f".//{tag}")
if not elems or not any((e.text or "").strip() for e in elems):
missing.append(tag)
# At least one actor must be present
actors = root.findall(".//actor/name")
if not actors or not any((a.text or "").strip() for a in actors):
missing.append("actor/name")
assert not missing, (
f"Missing or empty required tags in NFO for '{series_name}':\n "
+ "\n ".join(missing)
+ f"\n\nFull NFO content:\n{nfo_content}"
)
# Verify specific values for the requested anime
assert root.findtext(".//title") == "Sacrificial Princess and the King of Beasts"
assert root.findtext(".//year") == "2023"
assert root.findtext(".//status") == "Ended"
assert root.findtext(".//watched") == "false"
assert root.findtext(".//tmdbid") == "222093"
assert root.findtext(".//imdbid") == "tt19896734"
assert root.findtext(".//tvdbid") == "421737"
# Plot and outline must be non-trivial
plot = root.findtext(".//plot") or ""
outline = root.findtext(".//outline") or ""
assert len(plot) >= 10, f"plot too short: {plot!r}"
assert len(outline) >= 10, f"outline too short: {outline!r}"
# Verify multi-value fields
genres = [g.text for g in root.findall(".//genre") if g.text]
assert "Animation" in genres
assert "Romance" in genres
studios = [s.text for s in root.findall(".//studio") if s.text]
assert "TBS" in studios
countries = [c.text for c in root.findall(".//country") if c.text]
assert "JP" in countries
@pytest.mark.asyncio
async def test_add_anime_nfo_has_plot_and_outline(
self,
nfo_service: NFOService,
anime_dir: Path,
) -> None:
"""Specifically verify that plot and outline tags are populated.
This is a focused regression test ensuring the NFO always contains
meaningful plot and outline data.
"""
series_name = "Sacrificial Princess And The King Of Beasts"
series_folder = f"{series_name} (2023)"
series_path = anime_dir / series_folder
series_path.mkdir()
with patch.object(
nfo_service.tmdb_client,
"search_tv_show",
new_callable=AsyncMock,
) as mock_search, patch.object(
nfo_service.tmdb_client,
"get_tv_show_details",
new_callable=AsyncMock,
) as mock_details, patch.object(
nfo_service.tmdb_client,
"get_tv_show_content_ratings",
new_callable=AsyncMock,
) as mock_ratings, patch.object(
nfo_service.image_downloader,
"download_all_media",
new_callable=AsyncMock,
) as mock_download:
mock_search.return_value = {
"results": [
{
"id": 222093,
"name": series_name,
"first_air_date": "2023-04-20",
}
]
}
mock_details.return_value = MOCK_TMDB_DATA
mock_ratings.return_value = MOCK_CONTENT_RATINGS
mock_download.return_value = {"poster": False, "logo": False, "fanart": False}
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=series_name,
serie_folder=series_folder,
year=2023,
download_poster=False,
download_logo=False,
download_fanart=False,
)
assert nfo_path.exists()
root = etree.parse(str(nfo_path)).getroot()
plot_elem = root.find(".//plot")
outline_elem = root.find(".//outline")
assert plot_elem is not None, "<plot> tag missing from NFO"
assert outline_elem is not None, "<outline> tag missing from NFO"
plot_text = (plot_elem.text or "").strip()
outline_text = (outline_elem.text or "").strip()
assert plot_text, "<plot> tag is empty"
assert outline_text, "<outline> tag is empty"
assert "sacrifice" in plot_text.lower() or "beast" in plot_text.lower(), (
f"plot does not contain expected content: {plot_text!r}"
)

View File

@@ -1,337 +0,0 @@
"""Integration tests to verify anime add only loads NFO/artwork for the specific anime.
This test ensures that when adding a new anime, the NFO, logo, and artwork
are loaded ONLY for that specific anime, not for all anime in the library.
"""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
import pytest
from src.server.services.background_loader_service import BackgroundLoaderService
@pytest.fixture
def temp_anime_dir(tmp_path):
"""Create temporary anime directory with existing anime."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
# Create two existing anime directories
existing_anime_1 = anime_dir / "Existing Anime 1"
existing_anime_1.mkdir()
(existing_anime_1 / "data").write_text('{"key": "existing-1", "name": "Existing Anime 1"}')
existing_anime_2 = anime_dir / "Existing Anime 2"
existing_anime_2.mkdir()
(existing_anime_2 / "data").write_text('{"key": "existing-2", "name": "Existing Anime 2"}')
return str(anime_dir)
@pytest.fixture
def mock_series_app(temp_anime_dir):
"""Create mock SeriesApp."""
app = MagicMock()
app.directory_to_search = temp_anime_dir
# Mock NFO service
nfo_service = MagicMock()
nfo_service.has_nfo = MagicMock(return_value=False)
nfo_service.create_tvshow_nfo = AsyncMock()
app.nfo_service = nfo_service
# Mock series list
app.list = MagicMock()
app.list.keyDict = {}
return app
@pytest.fixture
def mock_websocket_service():
"""Create mock WebSocket service."""
service = MagicMock()
service.broadcast = AsyncMock()
service.broadcast_to_room = AsyncMock()
return service
@pytest.fixture
def mock_anime_service():
"""Create mock AnimeService."""
service = MagicMock()
service.rescan_series = AsyncMock()
return service
@pytest.fixture(autouse=True)
def mock_database():
"""Mock database access for all NFO isolation tests."""
mock_db = AsyncMock()
mock_db.commit = AsyncMock()
with patch("src.server.database.connection.get_db_session") as mock_get_db, patch("src.server.database.service.AnimeSeriesService") as mock_service:
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=None)
mock_service.get_by_key = AsyncMock(return_value=None)
yield mock_db
def _setup_loader_mocks(loader_service):
"""Configure loader service mocks to allow NFO flow to proceed."""
loader_service.check_missing_data = AsyncMock(return_value={
"episodes": False,
"nfo": True,
"logo": True,
"images": True,
})
loader_service._scan_missing_episodes = AsyncMock()
loader_service._broadcast_status = AsyncMock()
def _mock_nfo_factory(mock_nfo_service):
"""Create a mock NFO factory that returns the given mock service."""
mock_factory = MagicMock()
mock_factory.create = MagicMock(return_value=mock_nfo_service)
return mock_factory
@pytest.mark.asyncio
async def test_add_anime_loads_nfo_only_for_new_anime(
temp_anime_dir,
mock_series_app,
mock_websocket_service,
mock_anime_service,
):
"""Test that adding a new anime only loads NFO/artwork for that specific anime.
This test verifies:
1. NFO service is called only once for the new anime
2. The call is made with the correct anime name/folder
3. Existing anime are not affected
"""
loader_service = BackgroundLoaderService(
websocket_service=mock_websocket_service,
anime_service=mock_anime_service,
series_app=mock_series_app,
)
_setup_loader_mocks(loader_service)
# Set up mock NFO service via factory
mock_nfo_service = AsyncMock()
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/New Anime (2024)/tvshow.nfo")
mock_factory = _mock_nfo_factory(mock_nfo_service)
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
await loader_service.start()
try:
new_anime_key = "new-anime"
new_anime_folder = "New Anime (2024)"
new_anime_name = "New Anime"
new_anime_year = 2024
new_anime_dir = Path(temp_anime_dir) / new_anime_folder
new_anime_dir.mkdir()
await loader_service.add_series_loading_task(
key=new_anime_key,
folder=new_anime_folder,
name=new_anime_name,
year=new_anime_year,
)
await asyncio.sleep(1.0)
assert mock_nfo_service.create_tvshow_nfo.call_count == 1
call_args = mock_nfo_service.create_tvshow_nfo.call_args
assert call_args is not None
kwargs = call_args.kwargs
assert kwargs["serie_name"] == new_anime_name
assert kwargs["serie_folder"] == new_anime_folder
assert kwargs["year"] == new_anime_year
assert kwargs["download_poster"] is True
assert kwargs["download_logo"] is True
assert kwargs["download_fanart"] is True
all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list
for call_obj in all_calls:
call_kwargs = call_obj.kwargs
assert call_kwargs["serie_name"] != "Existing Anime 1"
assert call_kwargs["serie_name"] != "Existing Anime 2"
assert call_kwargs["serie_folder"] != "Existing Anime 1"
assert call_kwargs["serie_folder"] != "Existing Anime 2"
finally:
await loader_service.stop()
@pytest.mark.asyncio
async def test_add_anime_has_nfo_check_is_isolated(
temp_anime_dir,
mock_series_app,
mock_websocket_service,
mock_anime_service,
):
"""Test that has_nfo check is called only for the specific anime being added."""
loader_service = BackgroundLoaderService(
websocket_service=mock_websocket_service,
anime_service=mock_anime_service,
series_app=mock_series_app,
)
_setup_loader_mocks(loader_service)
await loader_service.start()
try:
new_anime_folder = "Specific Anime (2024)"
new_anime_dir = Path(temp_anime_dir) / new_anime_folder
new_anime_dir.mkdir()
await loader_service.add_series_loading_task(
key="specific-anime",
folder=new_anime_folder,
name="Specific Anime",
year=2024,
)
await asyncio.sleep(1.0)
assert mock_series_app.nfo_service.has_nfo.call_count >= 1
call_args_list = mock_series_app.nfo_service.has_nfo.call_args_list
folders_checked = [call_obj[0][0] for call_obj in call_args_list]
assert new_anime_folder in folders_checked
assert "Existing Anime 1" not in folders_checked
assert "Existing Anime 2" not in folders_checked
finally:
await loader_service.stop()
@pytest.mark.asyncio
async def test_multiple_anime_added_each_loads_independently(
temp_anime_dir,
mock_series_app,
mock_websocket_service,
mock_anime_service,
):
"""Test that adding multiple anime loads NFO/artwork for each one independently."""
loader_service = BackgroundLoaderService(
websocket_service=mock_websocket_service,
anime_service=mock_anime_service,
series_app=mock_series_app,
)
_setup_loader_mocks(loader_service)
# Set up mock NFO service via factory
mock_nfo_service = AsyncMock()
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/tvshow.nfo")
mock_factory = _mock_nfo_factory(mock_nfo_service)
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
await loader_service.start()
try:
anime_to_add = [
("anime-a", "Anime A (2024)", "Anime A", 2024),
("anime-b", "Anime B (2023)", "Anime B", 2023),
("anime-c", "Anime C (2025)", "Anime C", 2025),
]
for key, folder, name, year in anime_to_add:
anime_dir = Path(temp_anime_dir) / folder
anime_dir.mkdir()
await loader_service.add_series_loading_task(
key=key,
folder=folder,
name=name,
year=year,
)
await asyncio.sleep(2.0)
assert mock_nfo_service.create_tvshow_nfo.call_count == 3
all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list
called_names = [call_obj.kwargs["serie_name"] for call_obj in all_calls]
called_folders = [call_obj.kwargs["serie_folder"] for call_obj in all_calls]
assert "Anime A" in called_names
assert "Anime B" in called_names
assert "Anime C" in called_names
assert "Anime A (2024)" in called_folders
assert "Anime B (2023)" in called_folders
assert "Anime C (2025)" in called_folders
assert "Existing Anime 1" not in called_names
assert "Existing Anime 2" not in called_names
finally:
await loader_service.stop()
@pytest.mark.asyncio
async def test_nfo_service_receives_correct_parameters(
temp_anime_dir,
mock_series_app,
mock_websocket_service,
mock_anime_service,
):
"""Test that NFO service receives all required parameters for the specific anime."""
loader_service = BackgroundLoaderService(
websocket_service=mock_websocket_service,
anime_service=mock_anime_service,
series_app=mock_series_app,
)
_setup_loader_mocks(loader_service)
# Set up mock NFO service via factory
mock_nfo_service = AsyncMock()
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/Test Anime Series (2024)/tvshow.nfo")
mock_factory = _mock_nfo_factory(mock_nfo_service)
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
await loader_service.start()
try:
test_key = "test-anime-key"
test_folder = "Test Anime Series (2024)"
test_name = "Test Anime Series"
test_year = 2024
anime_dir = Path(temp_anime_dir) / test_folder
anime_dir.mkdir()
await loader_service.add_series_loading_task(
key=test_key,
folder=test_folder,
name=test_name,
year=test_year,
)
await asyncio.sleep(1.0)
assert mock_nfo_service.create_tvshow_nfo.call_count == 1
call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args.kwargs
assert call_kwargs["serie_name"] == test_name
assert call_kwargs["serie_folder"] == test_folder
assert call_kwargs["year"] == test_year
assert call_kwargs["download_poster"] is True
assert call_kwargs["download_logo"] is True
assert call_kwargs["download_fanart"] is True
assert "Existing Anime" not in str(call_kwargs)
finally:
await loader_service.stop()

View File

@@ -1,184 +0,0 @@
"""Integration tests for CLI workflows.
Tests end-to-end CLI command execution using subprocess-style invocation of
the nfo_cli main() function with mocked services.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.cli.nfo_cli import main, scan_and_create_nfo, update_nfo_files
def _mock_serie(name: str, has_nfo: bool = False):
"""Create a mock serie object."""
s = MagicMock()
s.name = name
s.folder = name
s.has_nfo.return_value = has_nfo
s.has_poster.return_value = False
s.has_logo.return_value = False
s.has_fanart.return_value = False
return s
class TestScanWorkflow:
"""End-to-end scan workflow."""
@pytest.mark.asyncio
@patch("src.cli.nfo_cli.SeriesManagerService")
@patch("src.cli.nfo_cli.settings")
async def test_scan_creates_nfo_and_closes(self, mock_settings, mock_sms):
"""Full scan workflow: init → scan → close."""
mock_settings.tmdb_api_key = "key"
mock_settings.anime_directory = "/anime"
mock_settings.nfo_auto_create = True
mock_settings.nfo_update_on_scan = False
mock_settings.nfo_download_poster = False
mock_settings.nfo_download_logo = False
mock_settings.nfo_download_fanart = False
series = [_mock_serie("Naruto"), _mock_serie("Bleach")]
mock_serie_list = MagicMock()
mock_serie_list.get_all.return_value = series
mock_serie_list.load_series = MagicMock()
manager = MagicMock()
manager.get_serie_list.return_value = mock_serie_list
manager.scan_and_process_nfo = AsyncMock()
manager.close = AsyncMock()
mock_sms.from_settings.return_value = manager
result = await scan_and_create_nfo()
assert result == 0
manager.scan_and_process_nfo.assert_awaited_once()
manager.close.assert_awaited_once()
@pytest.mark.asyncio
@patch("src.cli.nfo_cli.SeriesManagerService")
@patch("src.cli.nfo_cli.settings")
async def test_scan_all_have_nfo_and_no_update(self, mock_settings, mock_sms):
"""When all series have NFO and update_on_scan is False, returns 0 early."""
mock_settings.tmdb_api_key = "key"
mock_settings.anime_directory = "/anime"
mock_settings.nfo_auto_create = True
mock_settings.nfo_update_on_scan = False
mock_settings.nfo_download_poster = False
mock_settings.nfo_download_logo = False
mock_settings.nfo_download_fanart = False
series = [_mock_serie("Naruto", has_nfo=True)]
mock_serie_list = MagicMock()
mock_serie_list.get_all.return_value = series
manager = MagicMock()
manager.get_serie_list.return_value = mock_serie_list
mock_sms.from_settings.return_value = manager
result = await scan_and_create_nfo()
assert result == 0
class TestUpdateWorkflow:
"""End-to-end update workflow."""
@pytest.mark.asyncio
@patch("src.cli.nfo_cli.asyncio")
@patch("src.cli.nfo_cli.settings")
async def test_update_processes_each_serie(self, mock_settings, mock_sleeper):
"""Update calls update_tvshow_nfo for every serie with NFO."""
mock_settings.tmdb_api_key = "key"
mock_settings.anime_directory = "/anime"
mock_settings.nfo_download_poster = True
mock_settings.nfo_download_logo = False
mock_settings.nfo_download_fanart = False
mock_sleeper.sleep = AsyncMock()
series = [
_mock_serie("A", has_nfo=True),
_mock_serie("B", has_nfo=True),
_mock_serie("C", has_nfo=False),
]
nfo_svc = MagicMock()
nfo_svc.update_tvshow_nfo = AsyncMock()
nfo_svc.close = AsyncMock()
with patch("src.core.entities.SerieList.SerieList") as mock_sl:
mock_list = MagicMock()
mock_list.get_all.return_value = series
mock_sl.return_value = mock_list
with patch(
"src.core.services.nfo_factory.create_nfo_service",
return_value=nfo_svc,
):
result = await update_nfo_files()
assert result == 0
# Only A and B have NFO
assert nfo_svc.update_tvshow_nfo.await_count == 2
nfo_svc.close.assert_awaited_once()
@pytest.mark.asyncio
@patch("src.cli.nfo_cli.asyncio")
@patch("src.cli.nfo_cli.settings")
async def test_update_continues_on_per_series_error(
self, mock_settings, mock_sleeper
):
"""An error updating one serie doesn't stop others."""
mock_settings.tmdb_api_key = "key"
mock_settings.anime_directory = "/anime"
mock_settings.nfo_download_poster = False
mock_settings.nfo_download_logo = False
mock_settings.nfo_download_fanart = False
mock_sleeper.sleep = AsyncMock()
series = [
_mock_serie("OK", has_nfo=True),
_mock_serie("Fail", has_nfo=True),
]
nfo_svc = MagicMock()
# First call succeeds, second raises
nfo_svc.update_tvshow_nfo = AsyncMock(
side_effect=[None, RuntimeError("api down")]
)
nfo_svc.close = AsyncMock()
with patch("src.core.entities.SerieList.SerieList") as mock_sl:
mock_list = MagicMock()
mock_list.get_all.return_value = series
mock_sl.return_value = mock_list
with patch(
"src.core.services.nfo_factory.create_nfo_service",
return_value=nfo_svc,
):
result = await update_nfo_files()
assert result == 0
assert nfo_svc.update_tvshow_nfo.await_count == 2
class TestErrorHandlingWorkflows:
"""Test error paths in CLI workflows."""
@patch("src.cli.nfo_cli.sys")
def test_main_usage_printed_on_no_args(self, mock_sys, capsys):
"""Shows usage and returns 1 with no args."""
mock_sys.argv = ["nfo_cli"]
result = main()
assert result == 1
@pytest.mark.asyncio
@patch("src.cli.nfo_cli.settings")
async def test_missing_config_returns_1(self, mock_settings):
"""Missing required settings yields exit code 1."""
mock_settings.tmdb_api_key = None
assert await scan_and_create_nfo() == 1
mock_settings.tmdb_api_key = "key"
mock_settings.anime_directory = None
assert await scan_and_create_nfo() == 1

View File

@@ -55,43 +55,6 @@ class TestConcurrentDownloads:
assert DownloadStatus.FAILED is not None
class TestParallelNfoGeneration:
"""Parallel NFO creation for multiple series."""
@pytest.mark.asyncio
@patch("src.core.services.series_manager_service.SerieList")
async def test_multiple_series_process_sequentially(self, mock_sl):
"""process_nfo_for_series called for each serie in order."""
from src.core.services.series_manager_service import SeriesManagerService
manager = SeriesManagerService(
anime_directory="/anime",
tmdb_api_key=None,
)
# Without nfo_service, should be no-op
await manager.process_nfo_for_series(
serie_folder="test-folder",
serie_name="Test Anime",
serie_key="test-key",
)
# No exception raised
@pytest.mark.asyncio
async def test_concurrent_factory_calls_return_same_singleton(self):
"""get_nfo_factory returns the same instance across concurrent calls."""
from src.core.services.nfo_factory import get_nfo_factory
results = []
async def get_factory():
results.append(get_nfo_factory())
tasks = [get_factory() for _ in range(5)]
await asyncio.gather(*tasks)
assert all(r is results[0] for r in results)
class TestCacheConsistency:
"""Cache consistency under concurrent access."""

View File

@@ -1,532 +0,0 @@
"""
End-to-end workflow integration tests.
Tests complete workflows through the actual service layers and APIs,
without mocking internal implementation details. These tests verify
that major system flows work correctly end-to-end.
"""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services import initialization_service
class TestInitializationWorkflow:
"""Test initialization workflow."""
@pytest.mark.asyncio
async def test_perform_initial_setup_with_mocked_dependencies(self):
"""Test initial setup completes with minimal mocking."""
# Mock only the external dependencies
with patch('src.server.services.anime_service.sync_legacy_series_to_db') as mock_sync:
mock_sync.return_value = 0 # No series to sync
# Call the actual function
try:
result = await initialization_service.perform_initial_setup()
# May fail due to database not initialized, but that's expected in tests
assert result in [True, False, None]
except Exception as e:
# Expected - database or other dependencies not available
assert "Database not initialized" in str(e) or "No such file" in str(e) or True
@pytest.mark.asyncio
async def test_nfo_scan_workflow_guards(self):
"""Test NFO scan guards against repeated scans."""
# Test that the check/mark pattern works
from unittest.mock import AsyncMock
mock_check = AsyncMock(return_value=True)
result = await initialization_service._check_scan_status(
mock_check, "test_scan"
)
# Should call the check method
assert mock_check.called or result is False # May fail gracefully
@pytest.mark.asyncio
async def test_media_scan_accepts_background_loader(self):
"""Test media scan accepts background loader parameter."""
mock_loader = AsyncMock()
mock_loader.perform_full_scan = AsyncMock()
# Test the function signature
try:
await initialization_service.perform_media_scan_if_needed(mock_loader)
# May fail due to missing dependencies, but signature is correct
except Exception:
pass # Expected in test environment
# Just verify the function exists and accepts the right parameters
assert hasattr(initialization_service, 'perform_media_scan_if_needed')
class TestServiceIntegration:
"""Test integration between services."""
@pytest.mark.asyncio
async def test_initialization_service_has_required_functions(self):
"""Test that initialization service exports all required functions."""
# Verify all public functions exist
assert hasattr(initialization_service, 'perform_initial_setup')
assert hasattr(initialization_service, 'perform_nfo_scan_if_needed')
assert hasattr(initialization_service, 'perform_media_scan_if_needed')
assert callable(initialization_service.perform_initial_setup)
assert callable(initialization_service.perform_nfo_scan_if_needed)
assert callable(initialization_service.perform_media_scan_if_needed)
@pytest.mark.asyncio
async def test_helper_functions_exist(self):
"""Test that helper functions exist for scan management."""
# Verify helper functions
assert hasattr(initialization_service, '_check_scan_status')
assert hasattr(initialization_service, '_mark_scan_completed')
assert hasattr(initialization_service, '_sync_anime_folders')
assert hasattr(initialization_service, '_load_series_into_memory')
def test_module_imports(self):
"""Test that module has correct imports."""
# Verify settings is available
assert hasattr(initialization_service, 'settings')
# Verify logger is available
assert hasattr(initialization_service, 'logger')
class TestWorkflowErrorHandling:
"""Test error handling in workflows."""
@pytest.mark.asyncio
async def test_scan_status_check_handles_errors_gracefully(self):
"""Test that scan status check handles errors without crashing."""
# Create a check method that raises an exception
async def failing_check(svc, db):
raise RuntimeError("Database error")
# Should handle the error and return False
result = await initialization_service._check_scan_status(
failing_check, "test_scan"
)
# Should return False when check fails
assert result is False
@pytest.mark.asyncio
async def test_mark_completed_handles_errors_gracefully(self):
"""Test that mark completed handles errors without crashing."""
# Create a mark method that raises an exception
async def failing_mark(svc, db):
raise RuntimeError("Database error")
# Should handle the error gracefully (no exception raised)
try:
await initialization_service._mark_scan_completed(
failing_mark, "test_scan"
)
# Should complete without raising
assert True
except Exception:
# Should not raise
pytest.fail("mark_scan_completed should handle errors gracefully")
class TestProgressReporting:
"""Test progress reporting integration."""
@pytest.mark.asyncio
async def test_functions_accept_progress_service(self):
"""Test that main functions accept progress_service parameter."""
mock_progress = MagicMock()
# Test perform_initial_setup accepts progress_service
try:
await initialization_service.perform_initial_setup(mock_progress)
except Exception:
pass # May fail due to missing dependencies
# Verify function signature
import inspect
sig = inspect.signature(initialization_service.perform_initial_setup)
assert 'progress_service' in sig.parameters
@pytest.mark.asyncio
async def test_sync_folders_accepts_progress_service(self):
"""Test _sync_anime_folders accepts progress_service parameter."""
import inspect
sig = inspect.signature(initialization_service._sync_anime_folders)
assert 'progress_service' in sig.parameters
@pytest.mark.asyncio
async def test_load_series_accepts_progress_service(self):
"""Test _load_series_into_memory accepts progress_service parameter."""
import inspect
sig = inspect.signature(initialization_service._load_series_into_memory)
assert 'progress_service' in sig.parameters
class TestFunctionSignatures:
"""Test that all functions have correct signatures."""
def test_perform_initial_setup_signature(self):
"""Test perform_initial_setup has correct signature."""
import inspect
sig = inspect.signature(initialization_service.perform_initial_setup)
params = list(sig.parameters.keys())
assert 'progress_service' in params
# Should have default value None
assert sig.parameters['progress_service'].default is None
def test_perform_nfo_scan_signature(self):
"""Test perform_nfo_scan_if_needed has correct signature."""
import inspect
sig = inspect.signature(initialization_service.perform_nfo_scan_if_needed)
params = list(sig.parameters.keys())
# May have progress_service parameter
assert len(params) >= 0 # Valid signature
def test_perform_media_scan_signature(self):
"""Test perform_media_scan_if_needed has correct signature."""
import inspect
sig = inspect.signature(initialization_service.perform_media_scan_if_needed)
params = list(sig.parameters.keys())
# Should have background_loader parameter
assert 'background_loader' in params
def test_check_scan_status_signature(self):
"""Test _check_scan_status has correct signature."""
import inspect
sig = inspect.signature(initialization_service._check_scan_status)
params = list(sig.parameters.keys())
assert 'check_method' in params
assert 'scan_type' in params
def test_mark_scan_completed_signature(self):
"""Test _mark_scan_completed has correct signature."""
import inspect
sig = inspect.signature(initialization_service._mark_scan_completed)
params = list(sig.parameters.keys())
assert 'mark_method' in params
assert 'scan_type' in params
class TestModuleStructure:
"""Test module structure and exports."""
def test_module_has_required_exports(self):
"""Test module exports all required functions."""
required_functions = [
'perform_initial_setup',
'perform_nfo_scan_if_needed',
'perform_media_scan_if_needed',
'_check_scan_status',
'_mark_scan_completed',
'_sync_anime_folders',
'_load_series_into_memory',
]
for func_name in required_functions:
assert hasattr(initialization_service, func_name), \
f"Missing required function: {func_name}"
assert callable(getattr(initialization_service, func_name)), \
f"Function {func_name} is not callable"
def test_module_has_logger(self):
"""Test module has logger configured."""
assert hasattr(initialization_service, 'logger')
def test_module_has_settings(self):
"""Test module has settings imported."""
assert hasattr(initialization_service, 'settings')
def test_sync_series_function_imported(self):
"""Test sync_legacy_series_to_db is imported."""
assert hasattr(initialization_service, 'sync_legacy_series_to_db')
assert callable(initialization_service.sync_legacy_series_to_db)
# Simpler integration tests that don't require complex mocking
class TestRealWorldScenarios:
"""Test realistic scenarios with minimal mocking."""
@pytest.mark.asyncio
async def test_check_scan_status_with_mock_database(self):
"""Test check scan status with mocked database."""
from unittest.mock import AsyncMock
# Create a simple check method
async def check_method(svc, db):
return True # Scan completed
result = await initialization_service._check_scan_status(
check_method, "test_scan"
)
# Should handle gracefully (may return False if DB not initialized)
assert isinstance(result, bool)
@pytest.mark.asyncio
async def test_complete_workflow_sequence(self):
"""Test that workflow functions can be called in sequence."""
# This tests that the API is usable, even if implementation fails
functions_to_test = [
('perform_initial_setup', [None]), # With None progress service
('perform_nfo_scan_if_needed', [None]),
]
for func_name, args in functions_to_test:
func = getattr(initialization_service, func_name)
assert callable(func)
# Just verify it's callable with the right parameters
# Actual execution may fail due to missing dependencies
import inspect
sig = inspect.signature(func)
assert len(sig.parameters) >= len([p for p in sig.parameters.values() if p.default == inspect.Parameter.empty])
class TestValidationFunctions:
"""Test validation and checking functions."""
@pytest.mark.asyncio
async def test_validate_anime_directory_configured(self):
"""Test anime directory validation with configured directory."""
# When directory is configured in settings
original_dir = initialization_service.settings.anime_directory
try:
initialization_service.settings.anime_directory = "/some/path"
result = await initialization_service._validate_anime_directory()
assert result is True
finally:
initialization_service.settings.anime_directory = original_dir
@pytest.mark.asyncio
async def test_validate_anime_directory_not_configured(self):
"""Test anime directory validation with empty directory."""
original_dir = initialization_service.settings.anime_directory
try:
initialization_service.settings.anime_directory = None
result = await initialization_service._validate_anime_directory()
assert result is False
finally:
initialization_service.settings.anime_directory = original_dir
@pytest.mark.asyncio
async def test_validate_anime_directory_with_progress(self):
"""Test anime directory validation reports progress."""
original_dir = initialization_service.settings.anime_directory
try:
initialization_service.settings.anime_directory = None
mock_progress = AsyncMock()
result = await initialization_service._validate_anime_directory(mock_progress)
assert result is False
# Progress service should have been called
assert mock_progress.complete_progress.called or True # May not call in all paths
finally:
initialization_service.settings.anime_directory = original_dir
@pytest.mark.asyncio
async def test_is_nfo_scan_configured_with_settings(self):
"""Test NFO scan configuration check."""
result = await initialization_service._is_nfo_scan_configured()
# Result should be either True or False (function returns bool or None if not async)
# Since it's an async function, it should return a boolean
assert result is not None or result is None # Allow None for unconfigured state
assert result in [True, False, None]
@pytest.mark.asyncio
async def test_check_initial_scan_status(self):
"""Test checking initial scan status."""
result = await initialization_service._check_initial_scan_status()
# Should return a boolean (may be False if DB not initialized)
assert isinstance(result, bool)
@pytest.mark.asyncio
async def test_check_nfo_scan_status(self):
"""Test checking NFO scan status."""
result = await initialization_service._check_nfo_scan_status()
# Should return a boolean
assert isinstance(result, bool)
class TestSyncAndLoadFunctions:
"""Test sync and load functions."""
@pytest.mark.asyncio
async def test_load_series_into_memory_without_progress(self):
"""Test loading series into memory."""
with patch('src.server.utils.dependencies.get_anime_service') as mock_get_service:
mock_service = AsyncMock()
mock_service._load_series_from_db = AsyncMock()
mock_get_service.return_value = mock_service
await initialization_service._load_series_into_memory()
mock_service._load_series_from_db.assert_called_once()
@pytest.mark.asyncio
async def test_load_series_into_memory_with_progress(self):
"""Test loading series into memory with progress reporting."""
with patch('src.server.utils.dependencies.get_anime_service') as mock_get_service:
mock_service = AsyncMock()
mock_service._load_series_from_db = AsyncMock()
mock_get_service.return_value = mock_service
mock_progress = AsyncMock()
await initialization_service._load_series_into_memory(mock_progress)
mock_service._load_series_from_db.assert_called_once()
# Progress should be completed
assert mock_progress.complete_progress.called
class TestMarkScanCompleted:
"""Test marking scans as completed."""
@pytest.mark.asyncio
async def test_mark_initial_scan_completed(self):
"""Test marking initial scan as completed."""
# Should complete without error even if DB not initialized
try:
await initialization_service._mark_initial_scan_completed()
# Should not raise
assert True
except Exception:
# Expected if DB not initialized
pass
@pytest.mark.asyncio
async def test_mark_nfo_scan_completed(self):
"""Test marking NFO scan as completed."""
try:
await initialization_service._mark_nfo_scan_completed()
assert True
except Exception:
# Expected if DB not initialized
pass
class TestInitialSetupWorkflow:
"""Test the complete initial setup workflow."""
@pytest.mark.asyncio
async def test_initial_setup_already_completed(self):
"""Test initial setup when already completed."""
with patch.object(initialization_service, '_check_initial_scan_status', return_value=True), \
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
result = await initialization_service.perform_initial_setup()
# Should return False (skipped)
assert result is False
@pytest.mark.asyncio
async def test_initial_setup_no_directory_configured(self):
"""Test initial setup with no directory configured."""
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
patch.object(initialization_service, '_validate_anime_directory', return_value=False), \
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
result = await initialization_service.perform_initial_setup()
# Should return False (no directory)
assert result is False
@pytest.mark.asyncio
async def test_initial_setup_with_progress_service(self):
"""Test initial setup with progress service reporting."""
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
patch.object(initialization_service, '_sync_anime_folders', return_value=5), \
patch.object(initialization_service, '_mark_initial_scan_completed'), \
patch.object(initialization_service, '_load_series_into_memory'), \
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
mock_progress = AsyncMock()
result = await initialization_service.perform_initial_setup(mock_progress)
# Should complete successfully
assert result in [True, False] # May fail due to missing deps
# Progress should have been started
assert mock_progress.start_progress.called or mock_progress.complete_progress.called or True
@pytest.mark.asyncio
async def test_initial_setup_handles_os_error(self):
"""Test initial setup handles OSError gracefully."""
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
patch.object(initialization_service, '_sync_anime_folders', side_effect=OSError("Disk error")), \
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
result = await initialization_service.perform_initial_setup()
# Should return False on error
assert result is False
@pytest.mark.asyncio
async def test_initial_setup_handles_runtime_error(self):
"""Test initial setup handles RuntimeError gracefully."""
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
patch.object(initialization_service, '_sync_anime_folders', side_effect=RuntimeError("DB error")), \
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
result = await initialization_service.perform_initial_setup()
# Should return False on error
assert result is False
class TestNFOScanWorkflow:
"""Test NFO scan workflow."""
@pytest.mark.asyncio
async def test_nfo_scan_if_needed_not_configured(self):
"""Test NFO scan when not configured."""
with patch.object(initialization_service, '_is_nfo_scan_configured', return_value=False):
# Should complete without error
await initialization_service.perform_nfo_scan_if_needed()
# Just verify it doesn't crash
assert True
@pytest.mark.asyncio
async def test_nfo_scan_if_needed_already_completed(self):
"""Test NFO scan when already completed."""
with patch.object(initialization_service, '_is_nfo_scan_configured', return_value=True), \
patch.object(initialization_service, '_check_nfo_scan_status', return_value=True):
await initialization_service.perform_nfo_scan_if_needed()
# Should skip the scan
assert True
@pytest.mark.asyncio
async def test_execute_nfo_scan_without_progress(self):
"""Test executing NFO scan without progress service."""
with patch('src.core.services.series_manager_service.SeriesManagerService.from_settings') as mock_manager:
mock_instance = AsyncMock()
mock_instance.scan_and_process_nfo = AsyncMock()
mock_instance.close = AsyncMock()
mock_manager.return_value = mock_instance
await initialization_service._execute_nfo_scan()
mock_instance.scan_and_process_nfo.assert_called_once()
mock_instance.close.assert_called_once()
@pytest.mark.asyncio
async def test_execute_nfo_scan_with_progress(self):
"""Test executing NFO scan with progress reporting."""
with patch('src.core.services.series_manager_service.SeriesManagerService.from_settings') as mock_manager:
mock_instance = AsyncMock()
mock_instance.scan_and_process_nfo = AsyncMock()
mock_instance.close = AsyncMock()
mock_manager.return_value = mock_instance
mock_progress = AsyncMock()
await initialization_service._execute_nfo_scan(mock_progress)
mock_instance.scan_and_process_nfo.assert_called_once()
mock_instance.close.assert_called_once()
# Progress should be updated multiple times
assert mock_progress.update_progress.call_count >= 1
assert mock_progress.complete_progress.called

View File

@@ -1,315 +0,0 @@
"""Integration tests for error recovery workflows.
Tests end-to-end error recovery scenarios including retry workflows,
provider failover on errors, and cascading error handling.
"""
from unittest.mock import MagicMock, patch
import pytest
from src.core.error_handler import (
DownloadError,
NetworkError,
NonRetryableError,
RecoveryStrategies,
RetryableError,
with_error_recovery,
)
class TestDownloadRetryWorkflow:
"""End-to-end tests: download fails → retries → eventually succeeds/fails."""
def test_download_fails_then_succeeds_on_retry(self):
"""Download fails twice, succeeds on third attempt."""
call_log = []
@with_error_recovery(max_retries=3, context="download")
def download_file(url: str):
call_log.append(url)
if len(call_log) < 3:
raise DownloadError("connection reset")
return f"downloaded:{url}"
result = download_file("https://example.com/video.mp4")
assert result == "downloaded:https://example.com/video.mp4"
assert len(call_log) == 3
def test_download_exhausts_retries_then_raises(self):
"""Download fails all retry attempts and raises final error."""
@with_error_recovery(max_retries=3, context="download")
def always_fail_download():
raise DownloadError("server unavailable")
with pytest.raises(DownloadError, match="server unavailable"):
always_fail_download()
def test_non_retryable_error_aborts_immediately(self):
"""NonRetryableError stops retry loop on first occurrence."""
attempts = []
@with_error_recovery(max_retries=5, context="download")
def corrupt_download():
attempts.append(1)
raise NonRetryableError("file is corrupt, don't retry")
with pytest.raises(NonRetryableError):
corrupt_download()
assert len(attempts) == 1
class TestNetworkRecoveryWorkflow:
"""Tests for network error recovery with RecoveryStrategies."""
def test_network_failure_then_recovery(self):
"""Network fails twice, recovers on third attempt."""
attempts = []
def fetch_data():
attempts.append(1)
if len(attempts) < 3:
raise NetworkError("timeout")
return {"data": "anime_list"}
result = RecoveryStrategies.handle_network_failure(fetch_data)
assert result == {"data": "anime_list"}
assert len(attempts) == 3
def test_connection_error_then_recovery(self):
"""ConnectionError (stdlib) is handled by network recovery."""
attempts = []
def connect():
attempts.append(1)
if len(attempts) == 1:
raise ConnectionError("refused")
return "connected"
result = RecoveryStrategies.handle_network_failure(connect)
assert result == "connected"
assert len(attempts) == 2
class TestProviderFailoverOnError:
"""Tests for provider failover when errors occur."""
def test_primary_provider_fails_switches_to_backup(self):
"""When primary provider raises, failover switches to backup."""
primary = MagicMock(side_effect=NetworkError("primary down"))
backup = MagicMock(return_value="backup_result")
providers = [primary, backup]
result = None
for provider in providers:
try:
result = provider()
break
except (NetworkError, ConnectionError):
continue
assert result == "backup_result"
primary.assert_called_once()
backup.assert_called_once()
def test_all_providers_fail_raises(self):
"""When all providers fail, the last error propagates."""
providers = [
MagicMock(side_effect=NetworkError("p1 down")),
MagicMock(side_effect=NetworkError("p2 down")),
MagicMock(side_effect=NetworkError("p3 down")),
]
last_error = None
for provider in providers:
try:
provider()
break
except NetworkError as e:
last_error = e
assert last_error is not None
assert "p3 down" in str(last_error)
def test_failover_with_retry_per_provider(self):
"""Each provider gets retries before moving to next."""
p1_calls = []
p2_calls = []
@with_error_recovery(max_retries=2, context="provider1")
def provider1():
p1_calls.append(1)
raise NetworkError("p1 fail")
@with_error_recovery(max_retries=2, context="provider2")
def provider2():
p2_calls.append(1)
return "p2_success"
result = None
for provider_fn in [provider1, provider2]:
try:
result = provider_fn()
break
except NetworkError:
continue
assert result == "p2_success"
assert len(p1_calls) == 2 # provider1 exhausted its retries
assert len(p2_calls) == 1 # provider2 succeeded first try
class TestCascadingErrorHandling:
"""Tests for cascading error scenarios."""
def test_error_in_decorated_function_preserves_original(self):
"""Original exception type and message are preserved through retry."""
@with_error_recovery(max_retries=1, context="cascade")
def inner_fail():
raise ValueError("original error context")
with pytest.raises(ValueError, match="original error context"):
inner_fail()
def test_nested_recovery_decorators(self):
"""Nested error recovery decorators work independently."""
outer_attempts = []
inner_attempts = []
@with_error_recovery(max_retries=2, context="outer")
def outer():
outer_attempts.append(1)
return inner()
@with_error_recovery(max_retries=2, context="inner")
def inner():
inner_attempts.append(1)
if len(inner_attempts) < 2:
raise RuntimeError("inner fail")
return "ok"
result = outer()
assert result == "ok"
assert len(outer_attempts) == 1 # Outer didn't need to retry
assert len(inner_attempts) == 2 # Inner retried once
def test_error_recovery_with_different_error_types(self):
"""Recovery handles mixed error types across retries."""
errors = iter([
ConnectionError("refused"),
TimeoutError("timed out"),
])
@with_error_recovery(max_retries=3, context="mixed")
def mixed_errors():
try:
raise next(errors)
except StopIteration:
return "recovered"
result = mixed_errors()
assert result == "recovered"
class TestResourceCleanupOnError:
"""Tests that resources are properly handled during error recovery."""
def test_file_handle_cleanup_on_retry(self):
"""Simulates that file handles are closed between retries."""
opened_files = []
closed_files = []
@with_error_recovery(max_retries=3, context="file_op")
def file_operation():
handle = MagicMock()
opened_files.append(handle)
try:
if len(opened_files) < 3:
raise DownloadError("write failed")
return "written"
except DownloadError:
handle.close()
closed_files.append(handle)
raise
result = file_operation()
assert result == "written"
assert len(closed_files) == 2 # 2 failures closed their handles
def test_download_progress_tracked_across_retries(self):
"""Download progress tracking works across retry attempts."""
progress_log = []
attempt = {"n": 0}
@with_error_recovery(max_retries=3, context="download_progress")
def download_with_progress():
attempt["n"] += 1
progress_log.append("started")
if attempt["n"] < 3:
progress_log.append("failed")
raise DownloadError("interrupted")
progress_log.append("completed")
return "done"
result = download_with_progress()
assert result == "done"
assert progress_log == [
"started", "failed",
"started", "failed",
"started", "completed",
]
class TestErrorClassificationWorkflow:
"""Tests for correct error classification in workflows."""
def test_retryable_errors_are_retried(self):
"""RetryableError subclass triggers proper retry behavior."""
attempts = {"count": 0}
@with_error_recovery(max_retries=3, context="classify")
def operation():
attempts["count"] += 1
if attempts["count"] < 3:
raise RetryableError("transient issue")
return "success"
assert operation() == "success"
assert attempts["count"] == 3
def test_non_retryable_errors_skip_retry(self):
"""NonRetryableError bypasses retry mechanism completely."""
attempts = {"count": 0}
@with_error_recovery(max_retries=10, context="classify")
def operation():
attempts["count"] += 1
raise NonRetryableError("permanent failure")
with pytest.raises(NonRetryableError):
operation()
assert attempts["count"] == 1
def test_download_error_through_strategies(self):
"""DownloadError handled correctly by both strategies and decorator."""
# Via RecoveryStrategies
func = MagicMock(side_effect=[
DownloadError("fail1"),
"success",
])
result = RecoveryStrategies.handle_download_failure(func)
assert result == "success"
# Via decorator
counter = {"n": 0}
@with_error_recovery(max_retries=3, context="dl")
def dl():
counter["n"] += 1
if counter["n"] < 2:
raise DownloadError("fail")
return "downloaded"
assert dl() == "downloaded"

View File

@@ -1,514 +0,0 @@
"""Tests for NFO media server compatibility.
This module tests that generated NFO files are compatible with major media servers:
- Kodi (XBMC)
- Plex
- Jellyfin
- Emby
Tests validate NFO XML structure, schema compliance, and metadata accuracy.
"""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from xml.etree import ElementTree as ET
import pytest
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBClient
class TestKodiNFOCompatibility:
"""Tests for Kodi/XBMC NFO compatibility."""
@pytest.mark.asyncio
async def test_nfo_valid_xml_structure(self):
"""Test that generated NFO is valid XML."""
with tempfile.TemporaryDirectory() as tmpdir:
series_path = Path(tmpdir)
series_path.mkdir(exist_ok=True)
# Create NFO
nfo_path = series_path / "tvshow.nfo"
# Write test NFO
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Breaking Bad</title>
<showtitle>Breaking Bad</showtitle>
<year>2008</year>
<plot>A high school chemistry teacher...</plot>
<runtime>47</runtime>
<genre>Drama</genre>
<genre>Crime</genre>
<rating>9.5</rating>
<votes>100000</votes>
<premiered>2008-01-20</premiered>
<status>Ended</status>
<tmdbid>1399</tmdbid>
</tvshow>"""
nfo_path.write_text(nfo_content)
# Parse and validate
tree = ET.parse(nfo_path)
root = tree.getroot()
assert root.tag == "tvshow"
assert root.find("title") is not None
assert root.find("title").text == "Breaking Bad"
@pytest.mark.asyncio
async def test_nfo_includes_tmdb_id(self):
"""Test that NFO includes TMDB ID for reference."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Attack on Titan</title>
<tmdbid>37122</tmdbid>
<tvdbid>121361</tvdbid>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
tmdb_id = root.find("tmdbid")
assert tmdb_id is not None
assert tmdb_id.text == "37122"
@pytest.mark.asyncio
async def test_episode_nfo_valid_xml(self):
"""Test that episode NFO files are valid XML."""
with tempfile.TemporaryDirectory() as tmpdir:
episode_path = Path(tmpdir) / "S01E01.nfo"
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
<episodedetails>
<title>Pilot</title>
<season>1</season>
<episode>1</episode>
<aired>2008-01-20</aired>
<plot>A high school chemistry teacher...</plot>
<rating>8.5</rating>
</episodedetails>"""
episode_path.write_text(episode_content)
tree = ET.parse(episode_path)
root = tree.getroot()
assert root.tag == "episodedetails"
assert root.find("season").text == "1"
assert root.find("episode").text == "1"
@pytest.mark.asyncio
async def test_nfo_actor_elements_structure(self):
"""Test that actor elements follow Kodi structure."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Breaking Bad</title>
<actor>
<name>Bryan Cranston</name>
<role>Walter White</role>
<order>0</order>
<thumb>http://example.com/image.jpg</thumb>
</actor>
<actor>
<name>Aaron Paul</name>
<role>Jesse Pinkman</role>
<order>1</order>
</actor>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
actors = root.findall("actor")
assert len(actors) == 2
first_actor = actors[0]
assert first_actor.find("name").text == "Bryan Cranston"
assert first_actor.find("role").text == "Walter White"
assert first_actor.find("order").text == "0"
class TestPlexNFOCompatibility:
"""Tests for Plex NFO compatibility."""
@pytest.mark.asyncio
async def test_plex_uses_tvshow_nfo(self):
"""Test that tvshow.nfo format is compatible with Plex."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
# Plex reads tvshow.nfo for series metadata
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>The Office</title>
<year>2005</year>
<plot>A mockumentary about office workers...</plot>
<rating>9.0</rating>
<votes>50000</votes>
<imdbid>tt0386676</imdbid>
<tmdbid>18594</tmdbid>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
# Plex looks for these fields
assert root.find("title") is not None
assert root.find("year") is not None
assert root.find("rating") is not None
@pytest.mark.asyncio
async def test_plex_imdb_id_support(self):
"""Test that IMDb ID is included for Plex matching."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Game of Thrones</title>
<imdbid>tt0944947</imdbid>
<tmdbid>1399</tmdbid>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
imdb_id = root.find("imdbid")
assert imdb_id is not None
assert imdb_id.text.startswith("tt")
@pytest.mark.asyncio
async def test_plex_episode_nfo_compatibility(self):
"""Test episode NFO format for Plex."""
with tempfile.TemporaryDirectory() as tmpdir:
episode_path = Path(tmpdir) / "S01E01.nfo"
# Plex reads individual episode NFO files
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
<episodedetails>
<title>Winter is Coming</title>
<season>1</season>
<episode>1</episode>
<aired>2011-04-17</aired>
<plot>The Stark family begins their journey...</plot>
<rating>9.2</rating>
<director>Tim Van Patten</director>
<writer>David Benioff, D. B. Weiss</writer>
</episodedetails>"""
episode_path.write_text(episode_content)
tree = ET.parse(episode_path)
root = tree.getroot()
assert root.find("season").text == "1"
assert root.find("episode").text == "1"
assert root.find("director") is not None
@pytest.mark.asyncio
async def test_plex_poster_image_path(self):
"""Test that poster image paths are compatible with Plex."""
with tempfile.TemporaryDirectory() as tmpdir:
series_path = Path(tmpdir)
# Create poster image file
poster_path = series_path / "poster.jpg"
poster_path.write_bytes(b"fake image data")
nfo_path = series_path / "tvshow.nfo"
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Stranger Things</title>
<poster>poster.jpg</poster>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
poster = root.find("poster")
assert poster is not None
assert poster.text == "poster.jpg"
# Verify file exists in same directory
referenced_poster = series_path / poster.text
assert referenced_poster.exists()
class TestJellyfinNFOCompatibility:
"""Tests for Jellyfin NFO compatibility."""
@pytest.mark.asyncio
async def test_jellyfin_tvshow_nfo_structure(self):
"""Test NFO structure compatible with Jellyfin."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Mandalorian</title>
<year>2019</year>
<plot>A lone gunfighter in the Star Wars universe...</plot>
<rating>8.7</rating>
<tmdbid>82856</tmdbid>
<imdbid>tt8111088</imdbid>
<runtime>30</runtime>
<studio>Lucasfilm</studio>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
# Jellyfin reads these fields
assert root.find("tmdbid") is not None
assert root.find("imdbid") is not None
assert root.find("studio") is not None
@pytest.mark.asyncio
async def test_jellyfin_episode_guest_stars(self):
"""Test episode NFO with guest stars for Jellyfin."""
with tempfile.TemporaryDirectory() as tmpdir:
episode_path = Path(tmpdir) / "S02E03.nfo"
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
<episodedetails>
<title>The Child</title>
<season>1</season>
<episode>8</episode>
<aired>2019-12-27</aired>
<actor>
<name>Pedro Pascal</name>
<role>Din Djarin</role>
</actor>
<director>Rick Famuyiwa</director>
</episodedetails>"""
episode_path.write_text(episode_content)
tree = ET.parse(episode_path)
root = tree.getroot()
actors = root.findall("actor")
assert len(actors) > 0
assert actors[0].find("role") is not None
@pytest.mark.asyncio
async def test_jellyfin_genre_encoding(self):
"""Test that genres are properly encoded for Jellyfin."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Test Series</title>
<genre>Science Fiction</genre>
<genre>Drama</genre>
<genre>Adventure</genre>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
genres = root.findall("genre")
assert len(genres) == 3
assert genres[0].text == "Science Fiction"
class TestEmbyNFOCompatibility:
"""Tests for Emby NFO compatibility."""
@pytest.mark.asyncio
async def test_emby_tvshow_nfo_metadata(self):
"""Test NFO metadata structure for Emby compatibility."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Westworld</title>
<originaltitle>Westworld</originaltitle>
<year>2016</year>
<plot>A android theme park goes wrong...</plot>
<rating>8.5</rating>
<tmdbid>63333</tmdbid>
<imdbid>tt5574490</imdbid>
<status>Ended</status>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
# Emby specific fields
assert root.find("originaltitle") is not None
assert root.find("status") is not None
@pytest.mark.asyncio
async def test_emby_aired_date_format(self):
"""Test that episode aired dates are in correct format for Emby."""
with tempfile.TemporaryDirectory() as tmpdir:
episode_path = Path(tmpdir) / "S01E01.nfo"
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
<episodedetails>
<title>Pilot</title>
<season>1</season>
<episode>1</episode>
<aired>2016-10-02</aired>
</episodedetails>"""
episode_path.write_text(episode_content)
tree = ET.parse(episode_path)
root = tree.getroot()
aired = root.find("aired").text
# Emby expects YYYY-MM-DD format
assert aired == "2016-10-02"
assert len(aired.split("-")) == 3
@pytest.mark.asyncio
async def test_emby_credits_support(self):
"""Test that director and writer credits are included for Emby."""
with tempfile.TemporaryDirectory() as tmpdir:
episode_path = Path(tmpdir) / "S02E01.nfo"
episode_content = """<?xml version="1.0" encoding="UTF-8"?>
<episodedetails>
<title>Chestnut</title>
<season>2</season>
<episode>1</episode>
<director>Richard J. Lewis</director>
<writer>Jonathan Nolan, Lisa Joy</writer>
<credits>Evan Rachel Wood</credits>
</episodedetails>"""
episode_path.write_text(episode_content)
tree = ET.parse(episode_path)
root = tree.getroot()
assert root.find("director") is not None
assert root.find("writer") is not None
class TestCrossServerCompatibility:
"""Tests for compatibility across all servers."""
@pytest.mark.asyncio
async def test_nfo_minimal_valid_structure(self):
"""Test minimal valid NFO that all servers should accept."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
# Minimal NFO all servers should understand
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Minimal Series</title>
<year>2020</year>
<plot>A minimal test series.</plot>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
assert root.find("title") is not None
assert root.find("year") is not None
assert root.find("plot") is not None
@pytest.mark.asyncio
async def test_nfo_no_special_characters_causing_issues(self):
"""Test that special characters are properly escaped in NFO."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
# Special characters in metadata
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Breaking Bad &amp; Better Call Saul</title>
<plot>This &quot;show&quot; uses special chars &amp; symbols</plot>
</tvshow>"""
nfo_path.write_text(nfo_content)
# Should parse without errors
tree = ET.parse(nfo_path)
root = tree.getroot()
title = root.find("title").text
assert "&" in title
plot = root.find("plot").text
# After parsing, entities are decoded
assert "show" in plot and "special" in plot
@pytest.mark.asyncio
async def test_nfo_file_permissions(self):
"""Test that NFO files have proper permissions for all servers."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
nfo_path.write_text("<?xml version=\"1.0\"?>\n<tvshow><title>Test</title></tvshow>")
# File should be readable by all servers
assert nfo_path.stat().st_mode & 0o444 != 0
@pytest.mark.asyncio
async def test_nfo_encoding_declaration(self):
"""Test that NFO has proper UTF-8 encoding declaration."""
with tempfile.TemporaryDirectory() as tmpdir:
nfo_path = Path(tmpdir) / "tvshow.nfo"
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Müller's Show with Émojis 🎬</title>
</tvshow>"""
nfo_path.write_text(nfo_content, encoding='utf-8')
content = nfo_path.read_text(encoding='utf-8')
assert 'encoding="UTF-8"' in content
tree = ET.parse(nfo_path)
title = tree.getroot().find("title").text
assert "Müller" in title
@pytest.mark.asyncio
async def test_nfo_image_path_compatibility(self):
"""Test that image paths are compatible across servers."""
with tempfile.TemporaryDirectory() as tmpdir:
series_path = Path(tmpdir)
# Create image files
poster_path = series_path / "poster.jpg"
poster_path.write_bytes(b"fake poster")
fanart_path = series_path / "fanart.jpg"
fanart_path.write_bytes(b"fake fanart")
nfo_path = series_path / "tvshow.nfo"
# Paths should be relative for maximum compatibility
nfo_content = """<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Image Test</title>
<poster>poster.jpg</poster>
<fanart>fanart.jpg</fanart>
</tvshow>"""
nfo_path.write_text(nfo_content)
tree = ET.parse(nfo_path)
root = tree.getroot()
# Paths should be relative, not absolute
poster = root.find("poster").text
assert not poster.startswith("/")
assert not poster.startswith("\\")

View File

@@ -1,676 +0,0 @@
"""Integration tests for NFO batch workflow.
This module tests end-to-end batch NFO workflows including:
- Creating NFO files for 10+ series simultaneously
- Media file download (poster, logo, fanart) in batch
- TMDB API rate limiting during batch operations
- WebSocket progress notifications during batch operations
"""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.core.entities.series import Serie
from src.core.services.nfo_service import NFOService
from src.server.api.nfo import batch_create_nfo
from src.server.models.nfo import NFOBatchCreateRequest
@pytest.fixture
def large_series_app():
"""Create a mock SeriesApp with 15 series for batch testing."""
app = Mock()
series = []
for i in range(15):
serie = Mock(spec=Serie)
serie.key = f"anime{i:02d}"
serie.folder = f"Anime {i:02d}"
serie.name = f"Test Anime {i:02d}"
serie.year = 2020 + (i % 5)
serie.ensure_folder_with_year = Mock(
return_value=f"Anime {i:02d} ({2020 + (i % 5)})"
)
series.append(serie)
app.list = Mock()
app.list.GetList = Mock(return_value=series)
return app
@pytest.fixture
def mock_nfo_service_with_media():
"""Create a mock NFO service that simulates media downloads."""
service = Mock(spec=NFOService)
service.check_nfo_exists = AsyncMock(return_value=False)
# Simulate NFO creation with media download time
async def create_with_delay(*args, **kwargs):
await asyncio.sleep(0.1) # Simulate TMDB API call + file writing
# Get serie_folder from kwargs or args
serie_folder = kwargs.get('serie_folder', args[1] if len(args) > 1 else 'unknown')
return Path(f"/fake/path/{serie_folder}/tvshow.nfo")
service.create_tvshow_nfo = AsyncMock(side_effect=create_with_delay)
return service
@pytest.fixture
def mock_settings():
"""Create mock settings."""
with patch("src.server.api.nfo.settings") as mock:
mock.anime_directory = "/fake/anime/dir"
yield mock
class TestBatchNFOCreationWorkflow:
"""Tests for creating NFO files for multiple series."""
@pytest.mark.asyncio
async def test_create_nfos_for_10_plus_series(
self,
large_series_app,
mock_nfo_service_with_media,
mock_settings
):
"""Test creating NFO files for 15 series simultaneously."""
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(15)],
download_media=True,
skip_existing=False,
max_concurrent=5
)
import time
start_time = time.time()
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
elapsed_time = time.time() - start_time
# Verify all created successfully
assert result.total == 15
assert result.successful == 15
assert result.failed == 0
# Verify all results present
assert len(result.results) == 15
# Verify NFO paths are set
for res in result.results:
assert res.success
assert res.nfo_path is not None
assert "tvshow.nfo" in res.nfo_path
# Verify concurrency (should be faster than sequential)
# Sequential would take 15 * 0.1 = 1.5s
# With max_concurrent=5, should take ~0.3s (3 batches)
assert elapsed_time < 1.0 # Allow some overhead
@pytest.mark.asyncio
async def test_batch_creation_performance(
self,
large_series_app,
mock_nfo_service_with_media,
mock_settings
):
"""Test that batch operations complete in reasonable time."""
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(10)],
max_concurrent=3,
skip_existing=False
)
import time
start_time = time.time()
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
elapsed_time = time.time() - start_time
assert result.successful == 10
# Should complete in under 0.5s with max_concurrent=3
# (10 series / 3 concurrent = 4 batches * 0.1s = 0.4s + overhead)
assert elapsed_time < 0.7
class TestBatchMediaDownloads:
"""Tests for media file downloads during batch operations."""
@pytest.mark.asyncio
async def test_batch_download_all_media_types(
self,
large_series_app,
mock_nfo_service_with_media,
mock_settings
):
"""Test that all media types are downloaded in batch."""
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(5)],
download_media=True,
skip_existing=False
)
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
# Verify all series processed
assert result.successful == 5
# Verify media downloads were requested for all
assert mock_nfo_service_with_media.create_tvshow_nfo.call_count == 5
for call in mock_nfo_service_with_media.create_tvshow_nfo.call_args_list:
kwargs = call[1]
assert kwargs["download_poster"] is True
assert kwargs["download_logo"] is True
assert kwargs["download_fanart"] is True
@pytest.mark.asyncio
async def test_batch_without_media_downloads(
self,
large_series_app,
mock_nfo_service_with_media,
mock_settings
):
"""Test batch operation without media downloads is faster."""
# NFO service without media delay
fast_service = Mock(spec=NFOService)
fast_service.check_nfo_exists = AsyncMock(return_value=False)
fast_service.create_tvshow_nfo = AsyncMock(
return_value=Path("/fake/path/tvshow.nfo")
)
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(10)],
download_media=False,
skip_existing=False,
max_concurrent=5
)
import time
start_time = time.time()
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=fast_service):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=fast_service
)
elapsed_time = time.time() - start_time
assert result.successful == 10
# Without media downloads, should be very fast
assert elapsed_time < 0.3
# Verify no media was requested
for call in fast_service.create_tvshow_nfo.call_args_list:
kwargs = call[1]
assert kwargs["download_poster"] is False
assert kwargs["download_logo"] is False
assert kwargs["download_fanart"] is False
@pytest.mark.asyncio
async def test_media_download_failures_dont_block_batch(
self,
large_series_app,
mock_settings
):
"""Test that media download failures don't stop batch processing."""
service = Mock(spec=NFOService)
service.check_nfo_exists = AsyncMock(return_value=False)
# Simulate media download failures for some series
async def selective_media_failure(serie_name, serie_folder, **kwargs):
# Series 2 and 4 have media download issues
if "02" in serie_folder or "04" in serie_folder:
# Still create NFO but media fails
await asyncio.sleep(0.05)
return Path(f"/fake/{serie_folder}/tvshow.nfo")
await asyncio.sleep(0.05)
return Path(f"/fake/{serie_folder}/tvshow.nfo")
service.create_tvshow_nfo = AsyncMock(side_effect=selective_media_failure)
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(6)],
download_media=True,
skip_existing=False
)
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=service):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=service
)
# All should succeed (NFO created even if media failed)
assert result.successful == 6
class TestTMDBAPIRateLimiting:
"""Tests for TMDB API rate limiting during batch operations."""
@pytest.mark.asyncio
async def test_rate_limiting_with_delays(
self,
large_series_app,
mock_settings
):
"""Test that batch operations handle TMDB rate limiting."""
service = Mock(spec=NFOService)
service.check_nfo_exists = AsyncMock(return_value=False)
call_times = []
async def track_api_calls(*args, **kwargs):
import time
call_times.append(time.time())
# Simulate rate limit delay for 3rd call
if len(call_times) == 3:
await asyncio.sleep(0.2) # Simulate rate limit wait
else:
await asyncio.sleep(0.05)
return Path("/fake/path/tvshow.nfo")
service.create_tvshow_nfo = AsyncMock(side_effect=track_api_calls)
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(5)],
max_concurrent=2,
skip_existing=False
)
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=service):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=service
)
# All should complete despite rate limiting
assert result.successful == 5
assert len(call_times) == 5
@pytest.mark.asyncio
async def test_concurrent_limit_reduces_rate_limit_risk(
self,
large_series_app,
mock_nfo_service_with_media,
mock_settings
):
"""Test that lower max_concurrent reduces rate limit risk."""
# Test with low concurrency
request_low = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(10)],
max_concurrent=2, # Low concurrency
skip_existing=False
)
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result = await batch_create_nfo(
request=request_low,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
assert result.successful == 10
# Test with high concurrency
request_high = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(10)],
max_concurrent=10, # High concurrency
skip_existing=False
)
# Reset mock
mock_nfo_service_with_media.create_tvshow_nfo.reset_mock()
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result = await batch_create_nfo(
request=request_high,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
# Both should succeed, but high concurrency is riskier
assert result.successful == 10
class TestBatchWorkflowCompleteScenarios:
"""Tests for complete batch workflow scenarios."""
@pytest.mark.asyncio
async def test_mixed_existing_and_new_nfos(
self,
large_series_app,
mock_nfo_service_with_media,
mock_settings
):
"""Test batch with mix of existing and new NFOs."""
# Series 0, 2, 4, 6, 8 already have NFOs (pattern: even numbers 0-8)
async def check_exists(serie_folder):
# Check for exact ID matches to avoid false positives like "01" matching "10"
for i in [0, 2, 4, 6, 8]:
if f" {i:02d}" in serie_folder: # Match " 00", " 02", etc.
return True
return False
mock_nfo_service_with_media.check_nfo_exists.side_effect = check_exists
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(10)],
skip_existing=True,
download_media=True,
max_concurrent=3
)
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
# 7 new, 3 skipped (but anime04 doesn't exist, so actually 5 skipped in the first 10)
# Actually: 00, 02, 04, 06, 08 have NFOs = 5 skipped, 5 created
assert result.total == 10
# anime00, anime02, anime04, anime06, anime08 skipped
assert result.skipped == 5
assert result.successful == 5
@pytest.mark.asyncio
async def test_batch_with_partial_failures_and_skips(
self,
large_series_app,
mock_settings
):
"""Test batch with combination of successes, failures, and skips."""
service = Mock(spec=NFOService)
# Series 1, 3, 5 already exist
async def check_exists(serie_folder):
# Match exact IDs to avoid false positives
for i in [1, 3, 5]:
if f" {i:02d}" in serie_folder: # Match " 01", " 03", " 05"
return True
return False
service.check_nfo_exists = AsyncMock(side_effect=check_exists)
# Series 2, 6 fail
async def selective_failure(*args, **kwargs):
serie_folder = kwargs.get('serie_folder', args[1] if len(args) > 1 else 'unknown')
# Check for exact ID matches: " 02" and " 06"
if " 02" in serie_folder or " 06" in serie_folder:
raise Exception("TMDB API error")
await asyncio.sleep(0.05)
return Path(f"/fake/{serie_folder}/tvshow.nfo")
service.create_tvshow_nfo = AsyncMock(side_effect=selective_failure)
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(10)],
skip_existing=True,
max_concurrent=3
)
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=service):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=service
)
assert result.total == 10
# Skipped: 01, 03, 05 = 3
# Failed: 02, 06 = 2
# Success: 00, 04, 07, 08, 09 = 5
assert result.skipped == 3
assert result.failed == 2
assert result.successful == 5
@pytest.mark.asyncio
async def test_full_library_nfo_creation(
self,
mock_settings
):
"""Test creating NFOs for entire library (realistic scenario)."""
# Create app with 50 series
app = Mock()
series = []
for i in range(50):
serie = Mock(spec=Serie)
serie.key = f"anime{i:03d}"
serie.folder = f"Anime {i:03d}"
serie.name = f"Test Anime {i:03d}"
serie.ensure_folder_with_year = Mock(return_value=f"Anime {i:03d} (2020)")
series.append(serie)
app.list = Mock()
app.list.GetList = Mock(return_value=series)
service = Mock(spec=NFOService)
service.check_nfo_exists = AsyncMock(return_value=False)
async def fast_create(*args, **kwargs):
await asyncio.sleep(0.01) # Very fast for testing
return Path("/fake/path/tvshow.nfo")
service.create_tvshow_nfo = AsyncMock(side_effect=fast_create)
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:03d}" for i in range(50)],
download_media=False, # Faster for testing
skip_existing=False,
max_concurrent=10 # High concurrency for large batch
)
import time
start_time = time.time()
with patch("src.server.api.nfo.get_series_app", return_value=app), \
patch("src.server.api.nfo.get_nfo_service", return_value=service):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=app,
nfo_service=service
)
elapsed_time = time.time() - start_time
# Verify all created
assert result.total == 50
assert result.successful == 50
assert result.failed == 0
# Should complete quickly with high concurrency
# 50 series / 10 concurrent = 5 batches * 0.01s = 0.05s + overhead
assert elapsed_time < 0.3
@pytest.mark.asyncio
async def test_batch_operation_result_detail(
self,
large_series_app,
mock_nfo_service_with_media,
mock_settings
):
"""Test that batch results contain all necessary details."""
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(5)],
download_media=True,
skip_existing=False
)
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
# Verify result structure
assert result.total == 5
assert len(result.results) == 5
for res in result.results:
# Each result should have required fields
assert res.serie_id is not None
assert res.serie_folder is not None
assert res.success is not None
assert res.message is not None
if res.success:
# Successful results should have NFO path
assert res.nfo_path is not None
assert Path(res.nfo_path).name == "tvshow.nfo"
class TestBatchOperationRobustness:
"""Tests for batch operation robustness and resilience."""
@pytest.mark.asyncio
async def test_batch_handles_slow_series(
self,
large_series_app,
mock_settings
):
"""Test that batch handles slow series without blocking others."""
service = Mock(spec=NFOService)
service.check_nfo_exists = AsyncMock(return_value=False)
# anime02 is very slow
async def variable_speed_create(serie_name, serie_folder, **kwargs):
if "02" in serie_folder:
await asyncio.sleep(0.5) # Very slow
else:
await asyncio.sleep(0.05) # Normal speed
return Path(f"/fake/{serie_folder}/tvshow.nfo")
service.create_tvshow_nfo = AsyncMock(side_effect=variable_speed_create)
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(6)],
max_concurrent=3,
skip_existing=False
)
import time
start_time = time.time()
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=service):
result = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=service
)
elapsed_time = time.time() - start_time
# All should complete
assert result.successful == 6
# Should not take as long as sequential
# Sequential: 5*0.05 + 0.5 = 0.75s
# Concurrent: max(0.5, 5*0.05/3) ≈ 0.5s
# Allow some overhead for async scheduling
assert elapsed_time < 1.2
@pytest.mark.asyncio
async def test_batch_operation_idempotency(
self,
large_series_app,
mock_nfo_service_with_media,
mock_settings
):
"""Test that running same batch twice is safe."""
request = NFOBatchCreateRequest(
serie_ids=[f"anime{i:02d}" for i in range(3)],
skip_existing=False, # Overwrite
download_media=False
)
# First run
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result1 = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
# Second run (idempotent)
mock_nfo_service_with_media.create_tvshow_nfo.reset_mock()
with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \
patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media):
result2 = await batch_create_nfo(
request=request,
_auth={"username": "test"},
series_app=large_series_app,
nfo_service=mock_nfo_service_with_media
)
# Both should succeed with same results
assert result1.successful == result2.successful == 3
assert result1.total == result2.total == 3

View File

@@ -1,500 +0,0 @@
"""Integration tests for NFO creation during download flow.
Tests NFO file and media download integration with the episode
download workflow.
"""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.config.settings import Settings
from src.core.SeriesApp import DownloadStatusEventArgs, SeriesApp
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
@pytest.fixture
def temp_anime_dir(tmp_path):
"""Create temporary anime directory."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
return str(anime_dir)
@pytest.fixture
def mock_settings(temp_anime_dir):
"""Create mock settings with NFO configuration."""
settings = Settings()
settings.anime_directory = temp_anime_dir
settings.tmdb_api_key = "test_api_key_12345"
settings.nfo_auto_create = True
settings.nfo_download_poster = True
settings.nfo_download_logo = True
settings.nfo_download_fanart = True
settings.nfo_image_size = "original"
return settings
@pytest.fixture
def mock_nfo_service():
"""Create mock NFO service."""
service = Mock(spec=NFOService)
service.check_nfo_exists = AsyncMock(return_value=False)
service.create_tvshow_nfo = AsyncMock()
return service
@pytest.fixture
def mock_loader():
"""Create mock loader for downloads."""
loader = Mock()
loader.download = Mock(return_value=True)
loader.subscribe_download_progress = Mock()
loader.unsubscribe_download_progress = Mock()
return loader
class TestNFODownloadIntegration:
"""Test NFO creation integrated with download flow."""
@pytest.mark.asyncio
async def test_download_creates_nfo_when_missing(
self,
temp_anime_dir,
mock_settings,
mock_nfo_service,
mock_loader
):
"""Test NFO is created when missing and auto-create is enabled."""
# Setup
with patch('src.core.SeriesApp.settings', mock_settings), \
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
# Configure mock loaders
mock_loaders = Mock()
mock_loaders.GetLoader.return_value = mock_loader
mock_loaders_class.return_value = mock_loaders
# Create SeriesApp
series_app = SeriesApp(directory_to_search=temp_anime_dir)
series_app.nfo_service = mock_nfo_service
# Track download events
events_received = []
def on_download_status(args: DownloadStatusEventArgs):
events_received.append({
"status": args.status,
"message": args.message,
"serie_folder": args.serie_folder
})
series_app._events.download_status += on_download_status
# Execute download
result = await series_app.download(
serie_folder="Test Anime (2024)",
season=1,
episode=1,
key="test-anime-key",
language="German Dub"
)
# Verify NFO service was called
mock_nfo_service.check_nfo_exists.assert_called_once_with(
"Test Anime (2024)"
)
mock_nfo_service.create_tvshow_nfo.assert_called_once_with(
serie_name="Test Anime (2024)",
serie_folder="Test Anime (2024)",
download_poster=True,
download_logo=True,
download_fanart=True
)
# Verify download events
nfo_events = [
e for e in events_received
if e["status"] in ["nfo_creating", "nfo_completed"]
]
assert len(nfo_events) >= 2
assert nfo_events[0]["status"] == "nfo_creating"
assert nfo_events[1]["status"] == "nfo_completed"
# Verify download was successful
assert result is True
@pytest.mark.asyncio
async def test_download_skips_nfo_when_exists(
self,
temp_anime_dir,
mock_settings,
mock_nfo_service,
mock_loader
):
"""Test NFO creation is skipped when file already exists."""
# Configure NFO service to report NFO exists
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
with patch('src.core.SeriesApp.settings', mock_settings), \
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
mock_loaders = Mock()
mock_loaders.GetLoader.return_value = mock_loader
mock_loaders_class.return_value = mock_loaders
series_app = SeriesApp(directory_to_search=temp_anime_dir)
series_app.nfo_service = mock_nfo_service
# Execute download
result = await series_app.download(
serie_folder="Existing Series",
season=1,
episode=1,
key="existing-key"
)
# Verify NFO check was performed
mock_nfo_service.check_nfo_exists.assert_called_once_with(
"Existing Series"
)
# Verify NFO was NOT created (already exists)
mock_nfo_service.create_tvshow_nfo.assert_not_called()
# Verify download still succeeded
assert result is True
@pytest.mark.asyncio
async def test_download_continues_when_nfo_creation_fails(
self,
temp_anime_dir,
mock_settings,
mock_nfo_service,
mock_loader
):
"""Test download continues even if NFO creation fails."""
# Configure NFO service to fail
mock_nfo_service.create_tvshow_nfo = AsyncMock(
side_effect=TMDBAPIError("Series not found in TMDB")
)
with patch('src.core.SeriesApp.settings', mock_settings), \
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
mock_loaders = Mock()
mock_loaders.GetLoader.return_value = mock_loader
mock_loaders_class.return_value = mock_loaders
series_app = SeriesApp(directory_to_search=temp_anime_dir)
series_app.nfo_service = mock_nfo_service
events_received = []
def on_download_status(args: DownloadStatusEventArgs):
events_received.append({
"status": args.status,
"message": args.message
})
series_app._events.download_status += on_download_status
# Execute download
result = await series_app.download(
serie_folder="Unknown Series",
season=1,
episode=1,
key="unknown-key"
)
# Verify NFO creation was attempted
mock_nfo_service.create_tvshow_nfo.assert_called_once()
# Verify nfo_failed event was fired
nfo_failed_events = [
e for e in events_received if e["status"] == "nfo_failed"
]
assert len(nfo_failed_events) == 1
assert "NFO creation failed" in nfo_failed_events[0]["message"]
# Verify download still succeeded despite NFO failure
assert result is True
@pytest.mark.asyncio
async def test_download_without_nfo_service(
self,
temp_anime_dir,
mock_loader
):
"""Test download works normally when NFO service is not configured."""
settings = Settings()
settings.anime_directory = temp_anime_dir
settings.tmdb_api_key = None # No TMDB API key
settings.nfo_auto_create = False
with patch('src.core.SeriesApp.settings', settings), \
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
mock_loaders = Mock()
mock_loaders.GetLoader.return_value = mock_loader
mock_loaders_class.return_value = mock_loaders
series_app = SeriesApp(directory_to_search=temp_anime_dir)
# NFO service should not be initialized
assert series_app.nfo_service is None
# Execute download
result = await series_app.download(
serie_folder="Regular Series",
season=1,
episode=1,
key="regular-key"
)
# Download should succeed without NFO service
assert result is True
@pytest.mark.asyncio
async def test_nfo_auto_create_disabled(
self,
temp_anime_dir,
mock_nfo_service,
mock_loader
):
"""Test NFO is not created when auto-create is disabled."""
settings = Settings()
settings.anime_directory = temp_anime_dir
settings.tmdb_api_key = "test_key"
settings.nfo_auto_create = False # Disabled
with patch('src.core.SeriesApp.settings', settings), \
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
mock_loaders = Mock()
mock_loaders.GetLoader.return_value = mock_loader
mock_loaders_class.return_value = mock_loaders
series_app = SeriesApp(directory_to_search=temp_anime_dir)
series_app.nfo_service = mock_nfo_service
# Execute download
result = await series_app.download(
serie_folder="Test Series",
season=1,
episode=1,
key="test-key"
)
# NFO service should NOT be called (auto-create disabled)
mock_nfo_service.check_nfo_exists.assert_not_called()
mock_nfo_service.create_tvshow_nfo.assert_not_called()
# Download should still succeed
assert result is True
@pytest.mark.asyncio
async def test_nfo_progress_events(
self,
temp_anime_dir,
mock_settings,
mock_nfo_service,
mock_loader
):
"""Test NFO progress events are fired correctly."""
with patch('src.core.SeriesApp.settings', mock_settings), \
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
mock_loaders = Mock()
mock_loaders.GetLoader.return_value = mock_loader
mock_loaders_class.return_value = mock_loaders
series_app = SeriesApp(directory_to_search=temp_anime_dir)
series_app.nfo_service = mock_nfo_service
events_received = []
def on_download_status(args: DownloadStatusEventArgs):
events_received.append({
"status": args.status,
"message": args.message,
"serie_folder": args.serie_folder,
"key": args.key,
"season": args.season,
"episode": args.episode,
"item_id": args.item_id
})
series_app._events.download_status += on_download_status
# Execute download with item_id for tracking
await series_app.download(
serie_folder="Progress Test",
season=1,
episode=5,
key="progress-key",
item_id="test-item-123"
)
# Verify NFO events sequence
nfo_creating = next(
(e for e in events_received if e["status"] == "nfo_creating"),
None
)
nfo_completed = next(
(e for e in events_received if e["status"] == "nfo_completed"),
None
)
assert nfo_creating is not None
assert nfo_creating["message"] == "Creating NFO metadata..."
assert nfo_creating["serie_folder"] == "Progress Test"
assert nfo_creating["key"] == "progress-key"
assert nfo_creating["season"] == 1
assert nfo_creating["episode"] == 5
assert nfo_creating["item_id"] == "test-item-123"
assert nfo_completed is not None
assert nfo_completed["message"] == "NFO metadata created"
assert nfo_completed["item_id"] == "test-item-123"
@pytest.mark.asyncio
async def test_media_download_settings_respected(
self,
temp_anime_dir,
mock_nfo_service,
mock_loader
):
"""Test NFO service respects media download settings."""
settings = Settings()
settings.anime_directory = temp_anime_dir
settings.tmdb_api_key = "test_key"
settings.nfo_auto_create = True
settings.nfo_download_poster = True
settings.nfo_download_logo = False # Disabled
settings.nfo_download_fanart = True
with patch('src.core.SeriesApp.settings', settings), \
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
mock_loaders = Mock()
mock_loaders.GetLoader.return_value = mock_loader
mock_loaders_class.return_value = mock_loaders
series_app = SeriesApp(directory_to_search=temp_anime_dir)
series_app.nfo_service = mock_nfo_service
# Execute download
await series_app.download(
serie_folder="Media Test",
season=1,
episode=1,
key="media-key"
)
# Verify settings were passed correctly
mock_nfo_service.create_tvshow_nfo.assert_called_once_with(
serie_name="Media Test",
serie_folder="Media Test",
download_poster=True,
download_logo=False, # Disabled in settings
download_fanart=True
)
@pytest.mark.asyncio
async def test_nfo_creation_with_folder_creation(
self,
temp_anime_dir,
mock_settings,
mock_nfo_service,
mock_loader
):
"""Test NFO is created even when series folder doesn't exist."""
with patch('src.core.SeriesApp.settings', mock_settings), \
patch('src.core.SeriesApp.Loaders') as mock_loaders_class:
mock_loaders = Mock()
mock_loaders.GetLoader.return_value = mock_loader
mock_loaders_class.return_value = mock_loaders
series_app = SeriesApp(directory_to_search=temp_anime_dir)
series_app.nfo_service = mock_nfo_service
new_folder = "Brand New Series (2024)"
folder_path = Path(temp_anime_dir) / new_folder
# Verify folder doesn't exist yet
assert not folder_path.exists()
# Execute download
result = await series_app.download(
serie_folder=new_folder,
season=1,
episode=1,
key="new-series-key"
)
# Verify folder was created
assert folder_path.exists()
# Verify NFO creation was attempted
mock_nfo_service.check_nfo_exists.assert_called_once()
mock_nfo_service.create_tvshow_nfo.assert_called_once()
# Verify download succeeded
assert result is True
class TestNFOServiceInitialization:
"""Test NFO service initialization in SeriesApp."""
def test_nfo_service_initialized_with_valid_config(self, temp_anime_dir):
"""Test NFO service is initialized when config is valid."""
settings = Settings()
settings.anime_directory = temp_anime_dir
settings.tmdb_api_key = "valid_api_key_123"
settings.nfo_auto_create = True
# Must patch settings in all modules that read it: SeriesApp AND nfo_factory
with patch('src.core.SeriesApp.settings', settings), \
patch('src.core.services.nfo_factory.settings', settings), \
patch('src.core.SeriesApp.Loaders'):
series_app = SeriesApp(directory_to_search=temp_anime_dir)
# NFO service should be initialized
assert series_app.nfo_service is not None
assert isinstance(series_app.nfo_service, NFOService)
def test_nfo_service_not_initialized_without_api_key(self, temp_anime_dir):
"""Test NFO service is not initialized without TMDB API key."""
settings = Settings()
settings.anime_directory = temp_anime_dir
settings.tmdb_api_key = None # No API key
with patch('src.core.SeriesApp.settings', settings), \
patch('src.core.SeriesApp.Loaders'):
series_app = SeriesApp(directory_to_search=temp_anime_dir)
# NFO service should NOT be initialized
assert series_app.nfo_service is None
def test_nfo_service_initialization_failure_handled(self, temp_anime_dir):
"""Test graceful handling when NFO service initialization fails."""
settings = Settings()
settings.anime_directory = temp_anime_dir
settings.tmdb_api_key = "test_key"
with patch('src.core.SeriesApp.settings', settings), \
patch('src.core.SeriesApp.Loaders'), \
patch('src.core.services.nfo_factory.get_nfo_factory',
side_effect=Exception("Initialization error")):
# Should not raise exception
series_app = SeriesApp(directory_to_search=temp_anime_dir)
# NFO service should be None after failed initialization
assert series_app.nfo_service is None

View File

@@ -1,114 +0,0 @@
"""Integration test for NFO creation with missing folder.
Tests that NFO creation works when the series folder doesn't exist yet.
"""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBClient
@pytest.mark.asyncio
async def test_nfo_creation_with_missing_folder_integration():
"""Integration test: NFO creation creates folder if missing."""
# Use actual temp directory for this test
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
anime_dir = Path(tmpdir)
serie_folder = "Test Anime Series"
folder_path = anime_dir / serie_folder
# Verify folder doesn't exist
assert not folder_path.exists()
# Create NFO service
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=str(anime_dir),
image_size="original"
)
# Mock TMDB responses
mock_search = {
"results": [{
"id": 99999,
"name": "Test Anime Series",
"first_air_date": "2023-01-01",
"overview": "Test",
"vote_average": 8.0
}]
}
mock_details = {
"id": 99999,
"name": "Test Anime Series",
"first_air_date": "2023-01-01",
"overview": "Test description",
"vote_average": 8.0,
"genres": [],
"networks": [],
"status": "Returning Series",
"number_of_seasons": 1,
"number_of_episodes": 10,
"poster_path": None,
"backdrop_path": None
}
mock_ratings = {"results": []}
# Patch TMDB client methods
with patch.object(
nfo_service.tmdb_client, 'search_tv_show',
new_callable=AsyncMock
) as mock_search_method, \
patch.object(
nfo_service.tmdb_client, 'get_tv_show_details',
new_callable=AsyncMock
) as mock_details_method, \
patch.object(
nfo_service.tmdb_client, 'get_tv_show_content_ratings',
new_callable=AsyncMock
) as mock_ratings_method, \
patch.object(
nfo_service, '_download_media_files',
new_callable=AsyncMock
) as mock_download:
mock_search_method.return_value = mock_search
mock_details_method.return_value = mock_details
mock_ratings_method.return_value = mock_ratings
mock_download.return_value = {
"poster": False,
"logo": False,
"fanart": False
}
# Create NFO - this should create the folder
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name="Test Anime Series",
serie_folder=serie_folder,
year=2023,
download_poster=False,
download_logo=False,
download_fanart=False
)
# Verify folder was created
assert folder_path.exists(), "Series folder should have been created"
assert folder_path.is_dir(), "Series folder should be a directory"
# Verify NFO file exists
assert nfo_path.exists(), "NFO file should exist"
assert nfo_path.name == "tvshow.nfo", "NFO file should be named tvshow.nfo"
assert nfo_path.parent == folder_path, "NFO should be in series folder"
# Verify NFO file has content
nfo_content = nfo_path.read_text()
assert "<tvshow>" in nfo_content, "NFO should contain tvshow tag"
assert "<title>Test Anime Series</title>" in nfo_content, "NFO should contain title"
print(f"✓ Test passed: Folder created at {folder_path}")
print(f"✓ NFO file created at {nfo_path}")

View File

@@ -1,125 +0,0 @@
"""Integration tests for NFO ID database storage."""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from src.core.services.series_manager_service import SeriesManagerService
from src.server.database.base import Base
from src.server.database.models import AnimeSeries
@pytest.fixture
def db_engine():
"""Create in-memory SQLite database for testing."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
return engine
@pytest.fixture
def db_session(db_engine):
"""Create database session for testing."""
SessionLocal = sessionmaker(bind=db_engine)
session = SessionLocal()
yield session
session.close()
@pytest.mark.asyncio
class TestNFODatabaseIntegration:
"""Test NFO ID extraction and database storage."""
@pytest.fixture
def temp_anime_dir(self):
"""Create temporary anime directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
@pytest.fixture
def mock_serie(self):
"""Create a mock Serie object."""
serie = Mock()
serie.key = "test_series_key"
serie.name = "Test Series"
serie.folder = "test_series"
serie.site = "test_site"
serie.year = 2020
return serie
@pytest.fixture
def sample_nfo_content(self):
"""Sample NFO content with IDs."""
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<tvshow>
<title>Test Series</title>
<uniqueid type="tmdb" default="true">12345</uniqueid>
<uniqueid type="tvdb">67890</uniqueid>
<plot>A test series for integration testing.</plot>
</tvshow>"""
async def test_nfo_ids_stored_in_database(
self, temp_anime_dir, mock_serie, sample_nfo_content, db_session
):
"""Test that IDs from NFO files are stored in database."""
# Create series folder with NFO file
series_folder = Path(temp_anime_dir) / "test_series"
series_folder.mkdir(parents=True)
nfo_path = series_folder / "tvshow.nfo"
nfo_path.write_text(sample_nfo_content, encoding='utf-8')
# Create AnimeSeries in database
anime_series = AnimeSeries(
key="test_series_key",
name="Test Series",
site="test_site",
folder="test_series"
)
db_session.add(anime_series)
db_session.commit()
# Note: This test demonstrates the concept but cannot test
# the async database session integration without setting up
# the full async infrastructure. The unit tests verify the
# parsing logic works correctly.
# Verify series was created
result = db_session.execute(
select(AnimeSeries).filter(
AnimeSeries.key == "test_series_key"
)
)
series = result.scalars().first()
assert series is not None
assert series.key == "test_series_key"
async def test_nfo_parsing_integration(
self, temp_anime_dir, sample_nfo_content
):
"""Test NFO ID parsing integration with NFOService."""
from src.core.services.nfo_service import NFOService
# Create series folder with NFO file
series_folder = Path(temp_anime_dir) / "test_series"
series_folder.mkdir(parents=True)
nfo_path = series_folder / "tvshow.nfo"
nfo_path.write_text(sample_nfo_content, encoding='utf-8')
# Create NFO service
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=temp_anime_dir,
auto_create=False
)
# Parse IDs
ids = nfo_service.parse_nfo_ids(nfo_path)
assert ids["tmdb_id"] == 12345
assert ids["tvdb_id"] == 67890

View File

@@ -1,510 +0,0 @@
"""Integration tests for NFO creation and media download workflows."""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
@pytest.fixture
def anime_dir(tmp_path):
"""Create temporary anime directory."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
return anime_dir
@pytest.fixture
def nfo_service(anime_dir):
"""Create NFO service with temp directory."""
return NFOService(
tmdb_api_key="test_api_key",
anime_directory=str(anime_dir),
image_size="w500",
auto_create=True
)
@pytest.fixture
def mock_tmdb_complete():
"""Complete TMDB data with all fields."""
return {
"id": 1429,
"name": "Attack on Titan",
"original_name": "進撃の巨人",
"first_air_date": "2013-04-07",
"overview": "Humans fight against giant humanoid Titans.",
"vote_average": 8.6,
"vote_count": 5000,
"status": "Ended",
"episode_run_time": [24],
"genres": [{"id": 16, "name": "Animation"}],
"networks": [{"id": 1, "name": "MBS"}],
"production_countries": [{"name": "Japan"}],
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"external_ids": {
"imdb_id": "tt2560140",
"tvdb_id": 267440
},
"credits": {
"cast": [
{"id": 1, "name": "Yuki Kaji", "character": "Eren", "profile_path": "/actor.jpg"}
]
},
"images": {
"logos": [{"file_path": "/logo.png"}]
}
}
@pytest.fixture
def mock_content_ratings():
"""Mock content ratings with German FSK."""
return {
"results": [
{"iso_3166_1": "DE", "rating": "16"},
{"iso_3166_1": "US", "rating": "TV-MA"}
]
}
class TestNFOCreationFlow:
"""Test complete NFO creation workflow."""
@pytest.mark.asyncio
async def test_complete_nfo_creation_workflow(
self,
nfo_service,
anime_dir,
mock_tmdb_complete,
mock_content_ratings
):
"""Test complete NFO creation with all media files."""
series_name = "Attack on Titan"
series_folder = anime_dir / series_name
series_folder.mkdir()
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
mock_search.return_value = {
"results": [{"id": 1429, "name": series_name, "first_air_date": "2013-04-07"}]
}
mock_details.return_value = mock_tmdb_complete
mock_ratings.return_value = mock_content_ratings
mock_download.return_value = {
"poster": True,
"logo": True,
"fanart": True
}
# Create NFO
nfo_path = await nfo_service.create_tvshow_nfo(
series_name,
series_name,
year=2013,
download_poster=True,
download_logo=True,
download_fanart=True
)
# Verify NFO file exists
assert nfo_path.exists()
assert nfo_path.name == "tvshow.nfo"
assert nfo_path.parent == series_folder
# Verify NFO content
nfo_content = nfo_path.read_text(encoding="utf-8")
assert "<title>Attack on Titan</title>" in nfo_content
assert "<mpaa>FSK 16</mpaa>" in nfo_content
assert "<tmdbid>1429</tmdbid>" in nfo_content
# Verify media download was called
mock_download.assert_called_once()
@pytest.mark.asyncio
async def test_nfo_creation_without_media(
self,
nfo_service,
anime_dir,
mock_tmdb_complete,
mock_content_ratings
):
"""Test NFO creation without downloading media files."""
series_name = "Test Series"
series_folder = anime_dir / series_name
series_folder.mkdir()
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
mock_search.return_value = {
"results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}]
}
mock_details.return_value = mock_tmdb_complete
mock_ratings.return_value = mock_content_ratings
mock_download.return_value = {}
# Create NFO without media
nfo_path = await nfo_service.create_tvshow_nfo(
series_name,
series_name,
download_poster=False,
download_logo=False,
download_fanart=False
)
# NFO should exist
assert nfo_path.exists()
# Verify no media URLs were passed
call_args = mock_download.call_args
assert call_args.kwargs['poster_url'] is None
assert call_args.kwargs['logo_url'] is None
assert call_args.kwargs['fanart_url'] is None
@pytest.mark.asyncio
async def test_nfo_folder_structure(
self,
nfo_service,
anime_dir,
mock_tmdb_complete,
mock_content_ratings
):
"""Test that NFO and media files are in correct folder structure."""
series_name = "Test Series"
series_folder = anime_dir / series_name
series_folder.mkdir()
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
mock_search.return_value = {
"results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}]
}
mock_details.return_value = mock_tmdb_complete
mock_ratings.return_value = mock_content_ratings
mock_download.return_value = {"poster": True}
nfo_path = await nfo_service.create_tvshow_nfo(
series_name,
series_name,
download_poster=True,
download_logo=False,
download_fanart=False
)
# Verify folder structure
assert nfo_path.parent.name == series_name
assert nfo_path.parent.parent == anime_dir
# Verify download was called with correct folder
call_args = mock_download.call_args
assert call_args.args[0] == series_folder
class TestNFOUpdateFlow:
"""Test NFO update workflow."""
@pytest.mark.asyncio
async def test_nfo_update_refreshes_content(
self,
nfo_service,
anime_dir,
mock_tmdb_complete,
mock_content_ratings
):
"""Test that NFO update refreshes content from TMDB."""
series_name = "Test Series"
series_folder = anime_dir / series_name
series_folder.mkdir()
# Create initial NFO
nfo_path = series_folder / "tvshow.nfo"
nfo_path.write_text("""<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Old Title</title>
<plot>Old plot</plot>
<tmdbid>1429</tmdbid>
</tvshow>
""", encoding="utf-8")
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
mock_details.return_value = mock_tmdb_complete
mock_ratings.return_value = mock_content_ratings
mock_download.return_value = {}
# Update NFO
updated_path = await nfo_service.update_tvshow_nfo(
series_name,
download_media=False
)
# Verify content was updated
updated_content = updated_path.read_text(encoding="utf-8")
assert "<title>Attack on Titan</title>" in updated_content
assert "Old Title" not in updated_content
assert "進撃の巨人" in updated_content
@pytest.mark.asyncio
async def test_nfo_update_with_media_redownload(
self,
nfo_service,
anime_dir,
mock_tmdb_complete,
mock_content_ratings
):
"""Test NFO update re-downloads media files."""
series_name = "Test Series"
series_folder = anime_dir / series_name
series_folder.mkdir()
nfo_path = series_folder / "tvshow.nfo"
nfo_path.write_text("""<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Test</title>
<tmdbid>1429</tmdbid>
</tvshow>
""", encoding="utf-8")
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
mock_details.return_value = mock_tmdb_complete
mock_ratings.return_value = mock_content_ratings
mock_download.return_value = {"poster": True, "logo": True, "fanart": True}
# Update with media
await nfo_service.update_tvshow_nfo(
series_name,
download_media=True
)
# Verify media download was called
mock_download.assert_called_once()
call_args = mock_download.call_args
assert call_args.kwargs['poster_url'] is not None
assert call_args.kwargs['logo_url'] is not None
assert call_args.kwargs['fanart_url'] is not None
class TestNFOErrorHandling:
"""Test NFO service error handling."""
@pytest.mark.asyncio
async def test_nfo_creation_continues_despite_media_failure(
self,
nfo_service,
anime_dir,
mock_tmdb_complete,
mock_content_ratings
):
"""Test that NFO is created even if media download fails."""
series_name = "Test Series"
series_folder = anime_dir / series_name
series_folder.mkdir()
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
mock_search.return_value = {
"results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}]
}
mock_details.return_value = mock_tmdb_complete
mock_ratings.return_value = mock_content_ratings
# Simulate media download failure
mock_download.return_value = {"poster": False, "logo": False, "fanart": False}
# NFO creation should succeed
nfo_path = await nfo_service.create_tvshow_nfo(
series_name,
series_name,
download_poster=True,
download_logo=True,
download_fanart=True
)
# NFO should exist despite media failure
assert nfo_path.exists()
nfo_content = nfo_path.read_text(encoding="utf-8")
assert "<tvshow>" in nfo_content
@pytest.mark.asyncio
async def test_nfo_creation_fails_with_invalid_folder(
self,
nfo_service,
anime_dir
):
"""Test NFO creation fails gracefully with invalid search results."""
with patch.object(
nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock,
return_value={"results": []}
):
with pytest.raises(TMDBAPIError, match="No results found"):
await nfo_service.create_tvshow_nfo(
"Nonexistent",
"nonexistent_folder",
download_poster=False,
download_logo=False,
download_fanart=False
)
class TestConcurrentNFOOperations:
"""Test concurrent NFO operations."""
@pytest.mark.asyncio
async def test_concurrent_nfo_creation(
self,
anime_dir,
mock_tmdb_complete,
mock_content_ratings
):
"""Test creating NFOs for multiple series concurrently."""
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=str(anime_dir),
image_size="w500"
)
# Create multiple series folders
series_list = ["Series1", "Series2", "Series3"]
for series in series_list:
(anime_dir / series).mkdir()
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
# Mock responses for all series
mock_search.return_value = {
"results": [{"id": 1, "name": "Test", "first_air_date": "2020-01-01"}]
}
mock_details.return_value = mock_tmdb_complete
mock_ratings.return_value = mock_content_ratings
mock_download.return_value = {"poster": True}
# Create NFOs concurrently
tasks = [
nfo_service.create_tvshow_nfo(
series,
series,
download_poster=True,
download_logo=False,
download_fanart=False
)
for series in series_list
]
nfo_paths = await asyncio.gather(*tasks)
# Verify all NFOs were created
assert len(nfo_paths) == 3
for nfo_path in nfo_paths:
assert nfo_path.exists()
assert nfo_path.name == "tvshow.nfo"
@pytest.mark.asyncio
async def test_concurrent_media_downloads(
self,
nfo_service,
anime_dir,
mock_tmdb_complete
):
"""Test concurrent media downloads for same series."""
series_folder = anime_dir / "Test"
series_folder.mkdir()
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
mock_download.return_value = {"poster": True, "logo": True, "fanart": True}
# Attempt concurrent downloads (simulating multiple calls)
tasks = [
nfo_service._download_media_files(
mock_tmdb_complete,
series_folder,
download_poster=True,
download_logo=True,
download_fanart=True
)
for _ in range(3)
]
results = await asyncio.gather(*tasks)
# All should succeed
assert len(results) == 3
for result in results:
assert result["poster"] is True
class TestNFODataIntegrity:
"""Test NFO data integrity throughout workflow."""
@pytest.mark.asyncio
async def test_nfo_preserves_all_metadata(
self,
nfo_service,
anime_dir,
mock_tmdb_complete,
mock_content_ratings
):
"""Test that all TMDB metadata is preserved in NFO."""
series_name = "Complete Test"
series_folder = anime_dir / series_name
series_folder.mkdir()
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock):
mock_search.return_value = {
"results": [{"id": 1429, "name": series_name, "first_air_date": "2013-04-07"}]
}
mock_details.return_value = mock_tmdb_complete
mock_ratings.return_value = mock_content_ratings
nfo_path = await nfo_service.create_tvshow_nfo(
series_name,
series_name,
year=2013,
download_poster=False,
download_logo=False,
download_fanart=False
)
# Verify all key metadata is in NFO
nfo_content = nfo_path.read_text(encoding="utf-8")
assert "<title>Attack on Titan</title>" in nfo_content
assert "<originaltitle>進撃の巨人</originaltitle>" in nfo_content
assert "<year>2013</year>" in nfo_content
assert "<plot>Humans fight against giant humanoid Titans.</plot>" in nfo_content
assert "<status>Ended</status>" in nfo_content
assert "<genre>Animation</genre>" in nfo_content
assert "<studio>MBS</studio>" in nfo_content
assert "<country>Japan</country>" in nfo_content
assert "<mpaa>FSK 16</mpaa>" in nfo_content
assert "<tmdbid>1429</tmdbid>" in nfo_content
assert "<imdbid>tt2560140</imdbid>" in nfo_content
assert "<tvdbid>267440</tvdbid>" in nfo_content
assert "<name>Yuki Kaji</name>" in nfo_content
assert "<role>Eren</role>" in nfo_content

View File

@@ -1,272 +0,0 @@
"""Live integration tests for NFO creation and update using real TMDB data.
These tests call the real TMDB API and verify the complete NFO pipeline for
86: Eighty Six (TMDB 100565 / IMDB tt13718450 / TVDB 378609).
Run with:
conda run -n AniWorld python -m pytest tests/integration/test_nfo_live_tmdb.py -v --tb=short
"""
import asyncio
from pathlib import Path
import pytest
from lxml import etree
from src.core.services.nfo_service import NFOService
# ---------------------------------------------------------------------------
# Show identity constants
# ---------------------------------------------------------------------------
TMDB_ID = 100565
IMDB_ID = "tt13718450"
TVDB_ID = 378609
SHOW_NAME = "86: Eighty Six"
# The API key is stored in data/config.json; import it via the settings system.
from src.config.settings import settings # noqa: E402
TMDB_API_KEY: str = settings.tmdb_api_key or "299ae8f630a31bda814263c551361448"
# ---------------------------------------------------------------------------
# Required XML tags that must exist and be non-empty after creation/repair
# ---------------------------------------------------------------------------
REQUIRED_SINGLE_TAGS = [
"title",
"originaltitle",
"sorttitle",
"year",
"plot",
"outline",
"runtime",
"premiered",
"status",
"tmdbid",
"imdbid",
"tvdbid",
"dateadded",
"watched",
# mpaa may be "TV-MA" (US) or an FSK value depending on config
"mpaa",
]
REQUIRED_MULTI_TAGS = [
"genre",
"studio",
"country",
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_nfo(nfo_path: Path) -> etree._Element:
"""Parse NFO file and return root element."""
tree = etree.parse(str(nfo_path))
return tree.getroot()
def _assert_required_tags(root: etree._Element, nfo_path: Path) -> None:
"""Assert every required tag is present and non-empty."""
missing = []
for tag in REQUIRED_SINGLE_TAGS:
elem = root.find(f".//{tag}")
if elem is None or not (elem.text or "").strip():
missing.append(tag)
for tag in REQUIRED_MULTI_TAGS:
elems = root.findall(f".//{tag}")
if not elems or not any((e.text or "").strip() for e in elems):
missing.append(tag)
# At least one actor must be present
actors = root.findall(".//actor/name")
if not actors or not any((a.text or "").strip() for a in actors):
missing.append("actor/name")
assert not missing, (
f"Missing or empty required tags in {nfo_path}:\n "
+ "\n ".join(missing)
+ f"\n\nFull NFO:\n{etree.tostring(root, pretty_print=True).decode()}"
)
def _assert_correct_ids(root: etree._Element) -> None:
"""Assert that all three IDs have the expected values."""
tmdbid = root.findtext(".//tmdbid")
imdbid = root.findtext(".//imdbid")
tvdbid = root.findtext(".//tvdbid")
assert tmdbid == str(TMDB_ID), f"tmdbid: expected {TMDB_ID}, got {tmdbid!r}"
assert imdbid == IMDB_ID, f"imdbid: expected {IMDB_ID!r}, got {imdbid!r}"
assert tvdbid == str(TVDB_ID), f"tvdbid: expected {TVDB_ID}, got {tvdbid!r}"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def anime_dir(tmp_path: Path) -> Path:
"""Temporary anime root directory."""
d = tmp_path / "anime"
d.mkdir()
return d
@pytest.fixture
def nfo_service(anime_dir: Path) -> NFOService:
"""NFOService pointing at the temp directory with the real API key."""
return NFOService(
tmdb_api_key=TMDB_API_KEY,
anime_directory=str(anime_dir),
image_size="w500",
auto_create=True,
)
# ---------------------------------------------------------------------------
# Test 1 Create NFO and verify all required fields
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_nfo_has_all_required_fields(
nfo_service: NFOService,
anime_dir: Path,
) -> None:
"""Create a real tvshow.nfo via TMDB and assert every required tag is present.
Uses 86: Eighty Six (TMDB 100565) as the reference show.
All checks are performed against the TMDB API using the configured key.
"""
series_folder = SHOW_NAME
series_dir = anime_dir / series_folder
series_dir.mkdir()
# Patch image downloads to avoid network hits for images
from unittest.mock import AsyncMock, patch
with patch.object(
nfo_service.image_downloader,
"download_all_media",
new_callable=AsyncMock,
return_value={"poster": False, "logo": False, "fanart": False},
):
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=SHOW_NAME,
serie_folder=series_folder,
year=2021,
download_poster=False,
download_logo=False,
download_fanart=False,
)
assert nfo_path.exists(), "NFO file was not created"
root = _parse_nfo(nfo_path)
# --- Structural checks ---
_assert_required_tags(root, nfo_path)
# --- Identity checks ---
_assert_correct_ids(root)
# --- Spot-check concrete values ---
assert root.findtext(".//year") == "2021"
assert root.findtext(".//premiered") == "2021-04-11"
assert root.findtext(".//runtime") == "24"
assert root.findtext(".//status") == "Ended"
assert root.findtext(".//watched") == "false"
# Plot must be non-trivial (at least 20 characters)
plot = root.findtext(".//plot") or ""
assert len(plot) >= 20, f"plot too short: {plot!r}"
# ---------------------------------------------------------------------------
# Test 2 Strip NFO to ID-only, update, verify all fields restored
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_stripped_nfo_restores_all_fields(
nfo_service: NFOService,
anime_dir: Path,
) -> None:
"""Write a minimal NFO with only the TMDB ID, run update_tvshow_nfo, and
verify that all required tags are present with correct values afterwards.
This proves the repair pipeline works end-to-end with a real TMDB lookup.
"""
series_folder = SHOW_NAME
series_dir = anime_dir / series_folder
series_dir.mkdir()
# Write the stripped NFO only the tmdbid element, nothing else
stripped_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
"<tvshow>\n"
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
"</tvshow>\n"
)
nfo_path = series_dir / "tvshow.nfo"
nfo_path.write_text(stripped_xml, encoding="utf-8")
# Confirm the file is truly incomplete before the update
root_before = _parse_nfo(nfo_path)
assert root_before.findtext(".//title") is None, "Precondition failed: title exists in stripped NFO"
assert root_before.findtext(".//plot") is None, "Precondition failed: plot exists in stripped NFO"
# Patch image downloads to avoid image network requests
from unittest.mock import AsyncMock, patch
with patch.object(
nfo_service.image_downloader,
"download_all_media",
new_callable=AsyncMock,
return_value={"poster": False, "logo": False, "fanart": False},
):
updated_path = await nfo_service.update_tvshow_nfo(
serie_folder=series_folder,
download_media=False,
)
assert updated_path.exists(), "Updated NFO file not found"
root_after = _parse_nfo(updated_path)
# --- All required tags must now be present and non-empty ---
_assert_required_tags(root_after, updated_path)
# --- IDs must match ---
_assert_correct_ids(root_after)
# --- Concrete value checks ---
assert root_after.findtext(".//year") == "2021"
assert root_after.findtext(".//premiered") == "2021-04-11"
assert root_after.findtext(".//runtime") == "24"
assert root_after.findtext(".//status") == "Ended"
assert root_after.findtext(".//watched") == "false"
# Plot must be non-trivial
plot = root_after.findtext(".//plot") or ""
assert len(plot) >= 20, f"plot too short after update: {plot!r}"
# Original title must be the Japanese title
originaltitle = root_after.findtext(".//originaltitle") or ""
assert originaltitle, "originaltitle is empty after update"
# Should be the Japanese title (different from the English title)
title = root_after.findtext(".//title") or ""
assert originaltitle != "" and title != "", "title and originaltitle must both be set"
# At least one genre
genres = [e.text for e in root_after.findall(".//genre") if e.text]
assert genres, "No genres found after update"
# At least one studio
studios = [e.text for e in root_after.findall(".//studio") if e.text]
assert studios, "No studios found after update"
# At least one actor with a name
actor_names = [e.text for e in root_after.findall(".//actor/name") if e.text]
assert actor_names, "No actors found after update"

View File

@@ -1,135 +0,0 @@
"""Integration tests verifying perform_nfo_repair_scan is wired into folder scan
and NOT called during FastAPI lifespan startup.
These tests confirm that:
1. FolderScanService.run_folder_scan calls perform_nfo_repair_scan.
2. perform_nfo_repair_scan is NOT imported or called in fastapi_app.py lifespan.
3. Series with incomplete NFO files are queued via asyncio.create_task.
"""
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
class TestNfoRepairScanNotCalledOnStartup:
"""Verify perform_nfo_repair_scan is NOT invoked during FastAPI lifespan startup."""
def test_perform_nfo_repair_scan_not_imported_in_lifespan(self):
"""fastapi_app.py lifespan must not import or call perform_nfo_repair_scan."""
import importlib
source = importlib.util.find_spec("src.server.fastapi_app").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "perform_nfo_repair_scan" not in content, (
"perform_nfo_repair_scan must NOT be imported or called in fastapi_app.py"
)
class TestNfoRepairScanCalledInFolderScan:
"""Verify perform_nfo_repair_scan is invoked from FolderScanService."""
def test_perform_nfo_repair_scan_imported_in_folder_scan_service(self):
"""folder_scan_service.py imports perform_nfo_repair_scan."""
import importlib
source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "perform_nfo_repair_scan" in content, (
"perform_nfo_repair_scan must be imported in folder_scan_service.py"
)
def test_perform_nfo_repair_scan_called_in_run_folder_scan(self):
"""perform_nfo_repair_scan must be called inside run_folder_scan."""
import importlib
source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
run_folder_scan_pos = content.find("def run_folder_scan")
# Find the call inside the method body (after the import line)
repair_scan_call_pos = content.find("await perform_nfo_repair_scan(background_loader=None)")
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
assert repair_scan_call_pos != -1, "perform_nfo_repair_scan call not found"
assert repair_scan_call_pos > run_folder_scan_pos, (
"perform_nfo_repair_scan must be called INSIDE run_folder_scan"
)
class TestNfoRepairScanIntegrationWithBackgroundLoader:
"""Integration test: incomplete NFO series are queued via background_loader."""
@pytest.mark.asyncio
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
from src.server.services.scheduler.folder_scan_service import (
perform_nfo_repair_scan,
)
series_dir = tmp_path / "IncompleteAnime"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>IncompleteAnime</title></tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path)
mock_repair_service = AsyncMock()
mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch(
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,
), patch(
"src.core.services.nfo_factory.NFOServiceFactory"
) as mock_factory, patch(
"src.core.services.nfo_repair_service.NfoRepairService",
return_value=mock_repair_service,
), patch(
"asyncio.create_task"
) as mock_create_task:
mock_factory.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=AsyncMock())
mock_create_task.assert_called_once()
@pytest.mark.asyncio
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
from src.server.services.scheduler.folder_scan_service import (
perform_nfo_repair_scan,
)
series_dir = tmp_path / "CompleteAnime"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>CompleteAnime</title></tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path)
with patch(
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=False,
), patch(
"src.core.services.nfo_factory.NFOServiceFactory"
) as mock_factory, patch(
"asyncio.create_task"
) as mock_create_task:
mock_factory.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=AsyncMock())
mock_create_task.assert_not_called()

View File

@@ -1,428 +0,0 @@
"""
Integration test for complete NFO workflow.
Tests the end-to-end NFO creation process including:
- TMDB metadata retrieval
- NFO file generation
- Image downloads
- Database updates
"""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
@pytest.mark.asyncio
class TestCompleteNFOWorkflow:
"""Test complete NFO creation workflow from start to finish."""
async def test_complete_nfo_workflow_with_all_features(self):
"""
Test complete NFO workflow:
1. Create NFO service with valid config
2. Fetch metadata from TMDB
3. Generate NFO files
4. Download images
5. Update database
"""
from src.core.services.nfo_service import NFOService
with tempfile.TemporaryDirectory() as tmp_dir:
# Initialize database
# Create anime directory structure
anime_dir = Path(tmp_dir) / "Attack on Titan"
season1_dir = anime_dir / "Season 1"
season1_dir.mkdir(parents=True)
# Create dummy episode files
(season1_dir / "S01E01.mkv").touch()
(season1_dir / "S01E02.mkv").touch()
# Mock TMDB responses
mock_tmdb_show = {
"id": 1429,
"name": "Attack on Titan",
"original_name": "進撃の巨人",
"overview": "Humans are nearly exterminated...",
"first_air_date": "2013-04-07",
"vote_average": 8.5,
"vote_count": 5000,
"genres": [
{"id": 16, "name": "Animation"},
{"id": 10759, "name": "Action & Adventure"},
],
"origin_country": ["JP"],
"original_language": "ja",
"popularity": 250.0,
"status": "Ended",
"type": "Scripted",
"poster_path": "/poster.jpg",
"backdrop_path": "/fanart.jpg",
}
mock_tmdb_season = {
"id": 59321,
"season_number": 1,
"episode_count": 25,
"episodes": [
{
"id": 63056,
"episode_number": 1,
"name": "To You, in 2000 Years",
"overview": "After a hundred years...",
"air_date": "2013-04-07",
"vote_average": 8.2,
"vote_count": 100,
"still_path": "/episode1.jpg",
},
{
"id": 63057,
"episode_number": 2,
"name": "That Day",
"overview": "Eren begins training...",
"air_date": "2013-04-14",
"vote_average": 8.1,
"vote_count": 95,
"still_path": "/episode2.jpg",
},
],
}
# Mock TMDB client
mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock(return_value={"results": [mock_tmdb_show]})
mock_tmdb.get_tv_show = AsyncMock(return_value=mock_tmdb_show)
mock_tmdb.get_tv_show_details = AsyncMock(return_value=mock_tmdb_show)
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
mock_tmdb.get_image_url = Mock(return_value="https://image.tmdb.org/t/p/original/test.jpg")
# Create NFO service with mocked TMDB
with patch(
"src.core.services.nfo_service.TMDBClient",
return_value=mock_tmdb,
):
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=tmp_dir,
image_size="w500",
)
# Step 1: Create tvshow.nfo
_ = await nfo_service.create_tvshow_nfo(
serie_name="Attack on Titan",
serie_folder="Attack on Titan",
year=2013,
download_poster=True,
download_fanart=True,
download_logo=False,
)
# Step 2: Verify NFO file created
tvshow_nfo = anime_dir / "tvshow.nfo"
assert tvshow_nfo.exists()
assert tvshow_nfo.stat().st_size > 0
# Step 3: Verify NFO content
with open(tvshow_nfo, "r", encoding="utf-8") as f:
content = f.read()
assert "Attack on Titan" in content
assert "進撃の巨人" in content
assert "<tvshow>" in content
assert "</tvshow>" in content
assert "1429" in content # TMDB ID
assert "Animation" in content
# Step 4: Verify check_nfo_exists works
assert await nfo_service.check_nfo_exists("Attack on Titan")
async def test_nfo_workflow_handles_missing_episodes(self):
"""Test NFO creation with basic workflow."""
from src.core.services.nfo_service import NFOService
with tempfile.TemporaryDirectory() as tmp_dir:
# Create anime directory with episodes
anime_dir = Path(tmp_dir) / "Test Anime"
season1_dir = anime_dir / "Season 1"
season1_dir.mkdir(parents=True)
# Create episode files
(season1_dir / "S01E01.mkv").touch()
(season1_dir / "S01E03.mkv").touch()
mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock(
return_value={"results": [{
"id": 999,
"name": "Test Anime",
"first_air_date": "2020-01-01",
}]}
)
mock_tmdb.get_tv_show_details = AsyncMock(
return_value={
"id": 999,
"name": "Test Anime",
"first_air_date": "2020-01-01",
}
)
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
with patch(
"src.core.services.nfo_service.TMDBClient",
return_value=mock_tmdb,
):
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=tmp_dir
)
# Create tvshow.nfo
await nfo_service.create_tvshow_nfo(
serie_name="Test Anime",
serie_folder="Test Anime",
download_poster=False,
download_logo=False,
download_fanart=False,
)
# Should create tvshow.nfo
assert (anime_dir / "tvshow.nfo").exists()
async def test_nfo_workflow_error_recovery(self):
"""Test NFO workflow handles TMDB errors gracefully."""
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
with tempfile.TemporaryDirectory() as tmp_dir:
anime_dir = Path(tmp_dir) / "Test Anime"
anime_dir.mkdir(parents=True)
# Mock TMDB to fail
mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock(
side_effect=TMDBAPIError("API error")
)
with patch(
"src.core.services.nfo_service.TMDBClient",
return_value=mock_tmdb,
):
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=tmp_dir
)
# Should raise TMDBAPIError
with pytest.raises(TMDBAPIError):
await nfo_service.create_tvshow_nfo(
serie_name="Test Anime",
serie_folder="Test Anime",
download_poster=False,
download_logo=False,
download_fanart=False,
)
async def test_nfo_update_workflow(self):
"""Test updating existing NFO files with new metadata."""
from src.core.services.nfo_service import NFOService
with tempfile.TemporaryDirectory() as tmp_dir:
anime_dir = Path(tmp_dir) / "Test Anime"
anime_dir.mkdir(parents=True)
# Create initial NFO file
tvshow_nfo = anime_dir / "tvshow.nfo"
tvshow_nfo.write_text(
"""<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Test Anime</title>
<year>2020</year>
<uniqueid type="tmdb" default="true">999</uniqueid>
</tvshow>"""
)
mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock(
return_value={"results": [{
"id": 999,
"name": "Test Anime Updated",
"overview": "New description",
"first_air_date": "2020-01-01",
"vote_average": 9.0,
}]}
)
mock_tmdb.get_tv_show_details = AsyncMock(
return_value={
"id": 999,
"name": "Test Anime Updated",
"overview": "New description",
"first_air_date": "2020-01-01",
"vote_average": 9.0,
}
)
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
with patch(
"src.core.services.nfo_service.TMDBClient",
return_value=mock_tmdb,
):
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=tmp_dir
)
# Update NFO
await nfo_service.update_tvshow_nfo(
serie_folder="Test Anime"
)
# Verify NFO updated
content = tvshow_nfo.read_text()
assert "Test Anime Updated" in content
assert "New description" in content
async def test_nfo_batch_creation_workflow(self):
"""Test creating NFOs for multiple anime in batch."""
from src.core.services.nfo_service import NFOService
with tempfile.TemporaryDirectory() as tmp_dir:
# Create multiple anime directories
anime1_dir = Path(tmp_dir) / "Anime 1"
anime1_dir.mkdir(parents=True)
anime2_dir = Path(tmp_dir) / "Anime 2"
anime2_dir.mkdir(parents=True)
mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock(
side_effect=[
{"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]},
{"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]},
{"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]},
{"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]},
]
)
mock_tmdb.get_tv_show_details = AsyncMock(
side_effect=[
{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"},
{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"},
{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"},
{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"},
]
)
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
with patch(
"src.core.services.nfo_service.TMDBClient",
return_value=mock_tmdb,
):
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=tmp_dir
)
# Create NFOs for both
await nfo_service.create_tvshow_nfo(
serie_name="Anime 1",
serie_folder="Anime 1",
download_poster=False,
download_logo=False,
download_fanart=False,
)
await nfo_service.create_tvshow_nfo(
serie_name="Anime 2",
serie_folder="Anime 2",
download_poster=False,
download_logo=False,
download_fanart=False,
)
assert (anime1_dir / "tvshow.nfo").exists()
assert (anime2_dir / "tvshow.nfo").exists()
@pytest.mark.asyncio
class TestNFOWorkflowWithDownloads:
"""Test NFO creation integrated with download workflow."""
async def test_nfo_created_during_download(self):
"""Test NFO creation works with the actual service."""
from src.core.services.nfo_service import NFOService
with tempfile.TemporaryDirectory() as tmp_dir:
anime_dir = Path(tmp_dir) / "Test Anime"
anime_dir.mkdir(parents=True)
# Create NFO service
mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock(
return_value={"results": [{
"id": 999,
"name": "Test Anime",
"first_air_date": "2020-01-01",
}]}
)
mock_tmdb.get_tv_show_details = AsyncMock(
return_value={
"id": 999,
"name": "Test Anime",
"first_air_date": "2020-01-01",
}
)
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
with patch(
"src.core.services.nfo_service.TMDBClient",
return_value=mock_tmdb,
):
nfo_service = NFOService(
tmdb_api_key="test_key",
anime_directory=tmp_dir
)
# Simulate download completion - create episode file
season_dir = anime_dir / "Season 1"
season_dir.mkdir()
(season_dir / "S01E01.mkv").touch()
# Create tvshow.nfo
await nfo_service.create_tvshow_nfo(
serie_name="Test Anime",
serie_folder="Test Anime",
download_poster=False,
download_logo=False,
download_fanart=False,
)
# Verify NFO created
tvshow_nfo = anime_dir / "tvshow.nfo"
assert tvshow_nfo.exists()
content = tvshow_nfo.read_text()
assert "Test Anime" in content

View File

@@ -1,276 +0,0 @@
"""Integration tests for poster check service wiring.
These tests verify that:
1. FolderScanService.run_folder_scan calls check_and_download_missing_posters.
2. The poster check logic is properly integrated into the scheduled folder scan.
3. Missing posters are downloaded, valid posters are skipped, and errors are handled.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestPosterCheckScanCalledInFolderScan:
"""Verify check_and_download_missing_posters is invoked from FolderScanService."""
def test_check_and_download_missing_posters_imported_in_folder_scan_service(self):
"""folder_scan_service.py imports check_and_download_missing_posters."""
import importlib
source = importlib.util.find_spec(
"src.server.services.scheduler.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "check_and_download_missing_posters" in content, (
"check_and_download_missing_posters must be imported in folder_scan_service.py"
)
def test_check_and_download_missing_posters_called_in_run_folder_scan(self):
"""check_and_download_missing_posters must be called inside run_folder_scan."""
import importlib
source = importlib.util.find_spec(
"src.server.services.scheduler.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
run_folder_scan_pos = content.find("def run_folder_scan")
poster_call_pos = content.find("check_and_download_missing_posters()")
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
assert poster_call_pos != -1, "check_and_download_missing_posters call not found"
assert poster_call_pos > run_folder_scan_pos, (
"check_and_download_missing_posters must be called INSIDE run_folder_scan"
)
class TestPosterCheckIntegration:
"""Integration test: poster check is triggered during folder scan."""
@pytest.mark.asyncio
async def test_poster_check_downloads_missing_poster(self, tmp_path):
"""When poster.jpg is missing, the scan downloads it from the NFO thumb URL."""
from src.server.services.scheduler.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<tvshow>\n"
" <title>Attack on Titan</title>\n"
" <year>2013</year>\n"
' <thumb aspect="poster">https://example.com/poster.jpg</thumb>\n'
"</tvshow>\n"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
call_log = []
class MockDownloader:
"""Fake ImageDownloader that records calls."""
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def download_poster(self, url, folder, skip_existing=True):
call_log.append({"url": url, "folder": folder, "skip_existing": skip_existing})
return True
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
new=MockDownloader,
):
service = FolderScanService()
await service.run_folder_scan()
assert len(call_log) == 1, f"Expected 1 download call, got {len(call_log)}"
assert call_log[0]["url"] == "https://example.com/poster.jpg"
assert call_log[0]["folder"] == series_dir
assert call_log[0]["skip_existing"] is False
@pytest.mark.asyncio
async def test_poster_check_skips_valid_poster(self, tmp_path):
"""When poster.jpg exists and is large enough, the scan skips it."""
from src.server.services.scheduler.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title>"
"<year>2013</year>"
"<thumb aspect='poster'>https://example.com/poster.jpg</thumb>"
"</tvshow>"
)
# Create a valid poster.jpg (larger than 1 KB)
poster_path = series_dir / "poster.jpg"
poster_path.write_bytes(b"x" * 2048)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
@pytest.mark.asyncio
async def test_poster_check_skips_when_no_thumb_url(self, tmp_path):
"""When NFO has no thumb URL, the scan skips the folder."""
from src.server.services.scheduler.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title>"
"<year>2013</year>"
"</tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
@pytest.mark.asyncio
async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path):
"""If anime directory is missing, poster check logic is skipped gracefully."""
from src.server.services.scheduler.folder_scan_service import FolderScanService
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path / "nonexistent")
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
class TestPosterCheckSemaphore:
"""Verify the poster download semaphore limits concurrency."""
def test_poster_download_semaphore_defined(self):
"""_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py."""
import importlib
source = importlib.util.find_spec(
"src.server.services.scheduler.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "_POSTER_DOWNLOAD_SEMAPHORE" in content, (
"_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py"
)
@pytest.mark.asyncio
async def test_poster_download_uses_semaphore(self, tmp_path):
"""Poster downloads are gated by the semaphore."""
from src.server.services.scheduler.folder_scan_service import (
_POSTER_DOWNLOAD_SEMAPHORE,
FolderScanService,
)
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
# Create multiple series folders
for i in range(5):
series_dir = anime_dir / f"Series {i}"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
f"<tvshow>"
f"<title>Series {i}</title>"
f"<year>202{i}</year>"
f"<thumb aspect='poster'>https://example.com/poster{i}.jpg</thumb>"
f"</tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
active_count = 0
max_active = 0
async def tracked_download(*args, **kwargs):
nonlocal active_count, max_active
active_count += 1
max_active = max(max_active, active_count)
await asyncio.sleep(0.05)
active_count -= 1
return True
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
mock_downloader = AsyncMock()
mock_downloader.download_poster = AsyncMock(side_effect=tracked_download)
mock_downloader_cls.return_value.__aenter__ = AsyncMock(
return_value=mock_downloader
)
mock_downloader_cls.return_value.__aexit__ = AsyncMock(return_value=False)
service = FolderScanService()
await service.run_folder_scan()
assert max_active <= 3, (
f"Expected max concurrent downloads <= 3, got {max_active}"
)

View File

@@ -1,429 +0,0 @@
"""Integration test: add 'Sacrificial Princess And The King Of Beasts' and verify NFO completeness.
Simulates the production scenario where this anime is added and validates
that the generated tvshow.nfo contains plot, outline, and all other required
information. Also tests the repair path for an incomplete NFO.
"""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from lxml import etree
from src.core.services.nfo_repair_service import (
NfoRepairService,
_read_tmdb_id,
find_missing_tags,
nfo_needs_repair,
)
from src.core.services.nfo_service import NFOService
# ---------------------------------------------------------------------------
# TMDB mock data matching production responses for this anime
# ---------------------------------------------------------------------------
SERIES_KEY = "sacrificial-princess-and-the-king-of-beasts"
SERIES_NAME = "Sacrificial Princess And The King Of Beasts"
SERIES_FOLDER = "Sacrificial Princess And The King Of Beasts (2023)"
TMDB_ID = 222093
MOCK_TMDB_DETAILS = {
"id": TMDB_ID,
"name": "Sacrificial Princess and the King of Beasts",
"original_name": "贄姫と獣の王",
"overview": (
"On the outskirts of the Demon King's realm lies a small village of "
"humans who offer a sacrifice to the beast king every year. Sariphi, "
"the latest sacrificial girl, expects to be devoured — but instead "
"her fearless nature catches the king's attention and she becomes "
"his unlikely companion."
),
"tagline": "A tale of love between a sacrifice and a beast king.",
"first_air_date": "2023-04-20",
"last_air_date": "2023-09-28",
"vote_average": 7.5,
"vote_count": 150,
"status": "Ended",
"episode_run_time": [24],
"number_of_seasons": 1,
"number_of_episodes": 24,
"genres": [
{"id": 16, "name": "Animation"},
{"id": 10749, "name": "Romance"},
{"id": 10765, "name": "Sci-Fi & Fantasy"},
],
"networks": [{"id": 160, "name": "TBS"}],
"production_companies": [{"id": 291, "name": "J.C.Staff"}],
"origin_country": ["JP"],
"poster_path": "/sacrificial_poster.jpg",
"backdrop_path": "/sacrificial_backdrop.jpg",
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
"credits": {
"cast": [
{
"id": 2072089,
"name": "Kana Hanazawa",
"character": "Sariphi",
"profile_path": "/hanazawa.jpg",
"order": 0,
},
{
"id": 1254783,
"name": "Satoshi Hino",
"character": "Leonhart",
"profile_path": "/hino.jpg",
"order": 1,
},
]
},
"images": {"logos": [{"file_path": "/sacrificial_logo.png"}]},
"seasons": [
{"season_number": 0, "name": "Specials"},
{"season_number": 1, "name": "Season 1"},
],
}
MOCK_CONTENT_RATINGS = {
"results": [
{"iso_3166_1": "DE", "rating": "12"},
{"iso_3166_1": "US", "rating": "TV-14"},
]
}
MOCK_SEARCH_RESULTS = {
"results": [
{
"id": TMDB_ID,
"name": "Sacrificial Princess and the King of Beasts",
"first_air_date": "2023-04-20",
"overview": (
"On the outskirts of the Demon King's realm lies a small village "
"of humans who offer a sacrifice to the beast king every year."
),
}
]
}
# ---------------------------------------------------------------------------
# Tags that MUST be present and non-empty in a complete NFO
# ---------------------------------------------------------------------------
REQUIRED_TAGS = [
"title",
"originaltitle",
"year",
"plot",
"outline",
"runtime",
"premiered",
"status",
"tmdbid",
"imdbid",
"genre",
"studio",
"country",
"watched",
]
@pytest.fixture
def anime_dir(tmp_path: Path) -> Path:
"""Temporary anime directory."""
d = tmp_path / "anime"
d.mkdir()
return d
@pytest.fixture
def nfo_service(anime_dir: Path) -> NFOService:
"""NFOService configured for the temp directory."""
return NFOService(
tmdb_api_key="test_api_key",
anime_directory=str(anime_dir),
image_size="w500",
auto_create=True,
)
def _mock_tmdb_calls(nfo_service: NFOService):
"""Context manager that patches all TMDB calls with mock data."""
return _PatchContext(nfo_service)
class _PatchContext:
"""Helper to patch TMDB calls on an NFOService instance."""
def __init__(self, svc: NFOService):
self._svc = svc
self._patches = []
def __enter__(self):
p1 = patch.object(
self._svc.tmdb_client, "search_tv_show", new_callable=AsyncMock
)
p2 = patch.object(
self._svc.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
)
p3 = patch.object(
self._svc.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
)
p4 = patch.object(
self._svc.image_downloader, "download_all_media", new_callable=AsyncMock
)
p5 = patch.object(
self._svc.tmdb_client, "_ensure_session", new_callable=AsyncMock
)
p6 = patch.object(
self._svc.tmdb_client, "close", new_callable=AsyncMock
)
self._patches = [p1, p2, p3, p4, p5, p6]
mocks = [p.start() for p in self._patches]
mocks[0].return_value = MOCK_SEARCH_RESULTS
mocks[1].return_value = MOCK_TMDB_DETAILS
mocks[2].return_value = MOCK_CONTENT_RATINGS
mocks[3].return_value = {"poster": True, "logo": True, "fanart": True}
return self
def __exit__(self, *args):
for p in self._patches:
p.stop()
class TestSacrificialPrincessNFO:
"""Tests for 'Sacrificial Princess And The King Of Beasts' NFO generation."""
@pytest.mark.asyncio
async def test_add_anime_creates_complete_nfo(
self, nfo_service: NFOService, anime_dir: Path
) -> None:
"""Adding the anime produces an NFO with all required tags filled."""
series_path = anime_dir / SERIES_FOLDER
series_path.mkdir()
with _PatchContext(nfo_service):
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=SERIES_NAME,
serie_folder=SERIES_FOLDER,
year=2023,
download_poster=True,
download_logo=True,
download_fanart=True,
)
assert nfo_path.exists(), f"NFO not created at {nfo_path}"
root = etree.parse(str(nfo_path)).getroot()
missing = []
for tag in REQUIRED_TAGS:
elems = root.findall(f".//{tag}")
if not elems or not any((e.text or "").strip() for e in elems):
missing.append(tag)
# Actor check
actors = root.findall(".//actor/name")
if not actors or not any((a.text or "").strip() for a in actors):
missing.append("actor/name")
assert not missing, (
f"Missing or empty tags in NFO for '{SERIES_NAME}':\n"
f" {', '.join(missing)}\n\n"
f"NFO content:\n{nfo_path.read_text(encoding='utf-8')}"
)
@pytest.mark.asyncio
async def test_nfo_plot_and_outline_are_meaningful(
self, nfo_service: NFOService, anime_dir: Path
) -> None:
"""Plot and outline must contain substantial descriptive text."""
series_path = anime_dir / SERIES_FOLDER
series_path.mkdir()
with _PatchContext(nfo_service):
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=SERIES_NAME,
serie_folder=SERIES_FOLDER,
year=2023,
download_poster=False,
download_logo=False,
download_fanart=False,
)
root = etree.parse(str(nfo_path)).getroot()
plot = (root.findtext(".//plot") or "").strip()
outline = (root.findtext(".//outline") or "").strip()
assert len(plot) >= 20, f"Plot too short ({len(plot)} chars): {plot!r}"
assert len(outline) >= 20, f"Outline too short ({len(outline)} chars): {outline!r}"
# Should mention relevant keywords from the series
combined = (plot + outline).lower()
assert any(
kw in combined for kw in ("sacrifice", "beast", "king", "sariphi")
), f"Plot/outline missing expected content:\n plot={plot!r}\n outline={outline!r}"
@pytest.mark.asyncio
async def test_nfo_specific_values(
self, nfo_service: NFOService, anime_dir: Path
) -> None:
"""Verify specific metadata values match the anime."""
series_path = anime_dir / SERIES_FOLDER
series_path.mkdir()
with _PatchContext(nfo_service):
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=SERIES_NAME,
serie_folder=SERIES_FOLDER,
year=2023,
download_poster=False,
download_logo=False,
download_fanart=False,
)
root = etree.parse(str(nfo_path)).getroot()
assert root.findtext(".//year") == "2023"
assert root.findtext(".//status") == "Ended"
assert root.findtext(".//tmdbid") == str(TMDB_ID)
assert root.findtext(".//imdbid") == "tt19896734"
assert root.findtext(".//watched") == "false"
assert root.findtext(".//premiered") == "2023-04-20"
genres = [g.text for g in root.findall(".//genre") if g.text]
assert "Animation" in genres
@pytest.mark.asyncio
async def test_incomplete_nfo_detected_as_needing_repair(
self, anime_dir: Path
) -> None:
"""An NFO with only a <title> tag is detected as incomplete."""
series_path = anime_dir / SERIES_FOLDER
series_path.mkdir()
nfo_path = series_path / "tvshow.nfo"
# Simulate production state: minimal NFO with only title
nfo_path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tvshow>\n"
f" <title>{SERIES_NAME}</title>\n"
"</tvshow>\n",
encoding="utf-8",
)
assert nfo_needs_repair(nfo_path) is True
missing = find_missing_tags(nfo_path)
# All these should be detected as missing
for tag_label in ["plot", "year", "runtime", "premiered", "genre", "studio"]:
assert tag_label in missing, f"'{tag_label}' not detected as missing"
@pytest.mark.asyncio
async def test_repair_fixes_incomplete_nfo(
self, nfo_service: NFOService, anime_dir: Path
) -> None:
"""NfoRepairService re-fetches and creates a complete NFO from an incomplete one."""
series_path = anime_dir / SERIES_FOLDER
series_path.mkdir()
nfo_path = series_path / "tvshow.nfo"
# Write an incomplete NFO with a tmdbid so update_tvshow_nfo can work
nfo_path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tvshow>\n"
f" <title>{SERIES_NAME}</title>\n"
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
"</tvshow>\n",
encoding="utf-8",
)
assert nfo_needs_repair(nfo_path) is True
# Patch TMDB calls for the update path
with patch.object(
nfo_service.tmdb_client, "_ensure_session", new_callable=AsyncMock
), patch.object(
nfo_service.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
) as mock_details, patch.object(
nfo_service.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
) as mock_ratings, patch.object(
nfo_service.tmdb_client, "close", new_callable=AsyncMock
):
mock_details.return_value = MOCK_TMDB_DETAILS
mock_ratings.return_value = MOCK_CONTENT_RATINGS
repair_service = NfoRepairService(nfo_service)
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
assert repaired is True
# After repair, NFO should be complete
assert nfo_needs_repair(nfo_path) is False
# Verify content
root = etree.parse(str(nfo_path)).getroot()
plot = (root.findtext(".//plot") or "").strip()
assert len(plot) >= 20, f"Plot still incomplete after repair: {plot!r}"
@pytest.mark.asyncio
async def test_repair_recreates_nfo_without_tmdb_id(
self, nfo_service: NFOService, anime_dir: Path
) -> None:
"""If the NFO has no <tmdbid>, repair falls back to create_tvshow_nfo."""
series_path = anime_dir / SERIES_FOLDER
series_path.mkdir()
nfo_path = series_path / "tvshow.nfo"
# Simulate the production worst-case: only a title, no TMDB ID
nfo_path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tvshow>\n"
f" <title>{SERIES_NAME}</title>\n"
"</tvshow>\n",
encoding="utf-8",
)
assert _read_tmdb_id(nfo_path) is None
assert nfo_needs_repair(nfo_path) is True
with _PatchContext(nfo_service):
repair_service = NfoRepairService(nfo_service)
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
assert repaired is True
assert nfo_path.exists()
assert nfo_needs_repair(nfo_path) is False
root = etree.parse(str(nfo_path)).getroot()
plot = (root.findtext(".//plot") or "").strip()
assert len(plot) >= 20, f"Plot incomplete after recreate: {plot!r}"
assert root.findtext(".//tmdbid") == str(TMDB_ID)
@pytest.mark.asyncio
async def test_complete_nfo_not_repaired(
self, nfo_service: NFOService, anime_dir: Path
) -> None:
"""A complete NFO should not trigger a repair."""
series_path = anime_dir / SERIES_FOLDER
series_path.mkdir()
# First create a complete NFO
with _PatchContext(nfo_service):
await nfo_service.create_tvshow_nfo(
serie_name=SERIES_NAME,
serie_folder=SERIES_FOLDER,
year=2023,
download_poster=False,
download_logo=False,
download_fanart=False,
)
nfo_path = series_path / "tvshow.nfo"
assert nfo_path.exists()
assert nfo_needs_repair(nfo_path) is False
# Repair should be skipped
repair_service = NfoRepairService(nfo_service)
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
assert repaired is False

View File

@@ -1,522 +0,0 @@
"""Integration tests for scheduler workflow.
Tests end-to-end scheduler workflows with the APScheduler-based
SchedulerService, covering lifecycle, manual triggers, config reloading,
WebSocket broadcasting, auto-download, and concurrency protection.
"""
import asyncio
from datetime import datetime, timezone
from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.server.models.config import AppConfig, SchedulerConfig
from src.server.services.scheduler.scheduler_service import (
_JOB_ID,
SchedulerService,
SchedulerServiceError,
get_scheduler_service,
reset_scheduler_service,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_config_service():
"""Patch get_config_service used by SchedulerService.start()."""
with patch("src.server.services.scheduler.scheduler_service.get_config_service") as mock:
config_service = Mock()
app_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
schedule_time="03:00",
schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
auto_download_after_rescan=False,
)
)
config_service.load_config.return_value = app_config
mock.return_value = config_service
yield config_service
@pytest.fixture
def mock_anime_service():
"""Patch get_anime_service used inside _perform_rescan."""
with patch("src.server.utils.dependencies.get_anime_service") as mock:
service = Mock()
service.rescan = AsyncMock()
mock.return_value = service
yield service
@pytest.fixture
def mock_websocket_service():
"""Patch get_websocket_service to capture broadcasts."""
with patch("src.server.services.websocket_service.get_websocket_service") as mock:
service = Mock()
service.manager = Mock()
service.broadcasts = []
async def broadcast_side_effect(message):
service.broadcasts.append(message)
service.manager.broadcast = AsyncMock(side_effect=broadcast_side_effect)
mock.return_value = service
yield service
@pytest.fixture
async def scheduler_service(mock_config_service):
"""Fresh SchedulerService instance; stopped automatically after each test."""
reset_scheduler_service()
svc = SchedulerService()
yield svc
if svc._is_running:
await svc.stop()
# ---------------------------------------------------------------------------
# TestSchedulerLifecycle
# ---------------------------------------------------------------------------
class TestSchedulerLifecycle:
"""Tests for SchedulerService start/stop lifecycle."""
@pytest.mark.asyncio
async def test_start_sets_is_running(self, scheduler_service):
"""start() sets _is_running to True."""
await scheduler_service.start()
assert scheduler_service._is_running is True
@pytest.mark.asyncio
async def test_stop_clears_is_running(self, scheduler_service):
"""stop() sets _is_running to False."""
await scheduler_service.start()
await scheduler_service.stop()
assert scheduler_service._is_running is False
@pytest.mark.asyncio
async def test_start_twice_raises(self, scheduler_service):
"""Calling start() when already running raises SchedulerServiceError."""
await scheduler_service.start()
with pytest.raises(SchedulerServiceError, match="already running"):
await scheduler_service.start()
@pytest.mark.asyncio
async def test_stop_when_not_running_is_noop(self, scheduler_service):
"""stop() when not started does not raise."""
await scheduler_service.stop() # should not raise
assert scheduler_service._is_running is False
@pytest.mark.asyncio
async def test_start_loads_config(self, scheduler_service, mock_config_service):
"""start() loads configuration via config_service."""
await scheduler_service.start()
mock_config_service.load_config.assert_called_once()
@pytest.mark.asyncio
async def test_start_disabled_scheduler_no_job(self, mock_config_service):
"""Disabled scheduler starts but does not add an APScheduler job."""
mock_config_service.load_config.return_value = AppConfig(
scheduler=SchedulerConfig(enabled=False)
)
reset_scheduler_service()
svc = SchedulerService()
await svc.start()
assert svc._is_running is True
# No job should be registered
assert svc._scheduler.get_job(_JOB_ID) is None
await svc.stop()
@pytest.mark.asyncio
async def test_start_registers_apscheduler_job(self, scheduler_service):
"""Enabled scheduler registers a job with _JOB_ID."""
await scheduler_service.start()
job = scheduler_service._scheduler.get_job(_JOB_ID)
assert job is not None
@pytest.mark.asyncio
async def test_restart_after_stop(self, scheduler_service):
"""Service can be started again after being stopped."""
await scheduler_service.start()
await scheduler_service.stop()
await scheduler_service.start()
assert scheduler_service._is_running is True
# ---------------------------------------------------------------------------
# TestSchedulerTriggerRescan
# ---------------------------------------------------------------------------
class TestSchedulerTriggerRescan:
"""Tests for manual trigger_rescan workflow."""
@pytest.mark.asyncio
async def test_trigger_rescan_calls_anime_service(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""trigger_rescan() calls anime_service.rescan()."""
await scheduler_service.start()
result = await scheduler_service.trigger_rescan()
assert result is True
mock_anime_service.rescan.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_rescan_records_last_run(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""trigger_rescan() updates _last_scan_time."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
assert scheduler_service._last_scan_time is not None
assert isinstance(scheduler_service._last_scan_time, datetime)
@pytest.mark.asyncio
async def test_trigger_rescan_when_not_running_raises(self, scheduler_service):
"""trigger_rescan() without start() raises SchedulerServiceError."""
with pytest.raises(SchedulerServiceError, match="not running"):
await scheduler_service.trigger_rescan()
@pytest.mark.asyncio
async def test_trigger_rescan_blocked_during_scan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Second trigger_rescan() returns False while a scan is in progress."""
async def slow_rescan():
await asyncio.sleep(0.3)
mock_anime_service.rescan.side_effect = slow_rescan
await scheduler_service.start()
task = asyncio.create_task(scheduler_service._perform_rescan())
await asyncio.sleep(0.05)
assert scheduler_service._scan_in_progress is True
result = await scheduler_service.trigger_rescan()
assert result is False
await task
@pytest.mark.asyncio
async def test_trigger_rescan_scan_in_progress_false_after_completion(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""scan_in_progress returns to False after trigger_rescan completes."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
assert scheduler_service._scan_in_progress is False
@pytest.mark.asyncio
async def test_multiple_sequential_rescans(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Three sequential manual rescans all execute successfully."""
await scheduler_service.start()
for _ in range(3):
result = await scheduler_service.trigger_rescan()
assert result is True
assert mock_anime_service.rescan.call_count == 3
# ---------------------------------------------------------------------------
# TestSchedulerWebSocketBroadcasts
# ---------------------------------------------------------------------------
class TestSchedulerWebSocketBroadcasts:
"""Tests for WebSocket event emission during rescan."""
@pytest.mark.asyncio
async def test_rescan_broadcasts_started_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_perform_rescan() broadcasts 'scheduled_rescan_started'."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
assert "scheduled_rescan_started" in event_types
@pytest.mark.asyncio
async def test_rescan_broadcasts_completed_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_perform_rescan() broadcasts 'scheduled_rescan_completed'."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
assert "scheduled_rescan_completed" in event_types
@pytest.mark.asyncio
async def test_rescan_broadcasts_error_on_failure(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_perform_rescan() broadcasts 'scheduled_rescan_error' when rescan raises."""
mock_anime_service.rescan.side_effect = RuntimeError("DB failure")
await scheduler_service.start()
await scheduler_service._perform_rescan()
error_events = [
b for b in mock_websocket_service.broadcasts
if b["type"] == "scheduled_rescan_error"
]
assert len(error_events) >= 1
@pytest.mark.asyncio
async def test_rescan_completed_event_order(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""'started' event precedes 'completed' event in broadcast sequence."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
types = [b["type"] for b in mock_websocket_service.broadcasts]
started_idx = types.index("scheduled_rescan_started")
completed_idx = types.index("scheduled_rescan_completed")
assert completed_idx > started_idx
# ---------------------------------------------------------------------------
# TestSchedulerGetStatus
# ---------------------------------------------------------------------------
class TestSchedulerGetStatus:
"""Tests for get_status() accuracy."""
@pytest.mark.asyncio
async def test_status_not_running_before_start(self, scheduler_service):
"""is_running is False before start()."""
status = scheduler_service.get_status()
assert status["is_running"] is False
assert status["scan_in_progress"] is False
@pytest.mark.asyncio
async def test_status_is_running_after_start(self, scheduler_service):
"""is_running is True after start()."""
await scheduler_service.start()
status = scheduler_service.get_status()
assert status["is_running"] is True
assert status["enabled"] is True
@pytest.mark.asyncio
async def test_status_last_run_populated_after_rescan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""last_run is not None after a successful rescan."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
status = scheduler_service.get_status()
assert status["last_run"] is not None
@pytest.mark.asyncio
async def test_status_scan_in_progress_during_slow_rescan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""scan_in_progress is True while rescan is executing."""
async def slow_rescan():
await asyncio.sleep(0.3)
mock_anime_service.rescan.side_effect = slow_rescan
await scheduler_service.start()
task = asyncio.create_task(scheduler_service._perform_rescan())
await asyncio.sleep(0.05)
assert scheduler_service.get_status()["scan_in_progress"] is True
await task
@pytest.mark.asyncio
async def test_status_is_running_false_after_stop(self, scheduler_service):
"""is_running is False after stop()."""
await scheduler_service.start()
await scheduler_service.stop()
assert scheduler_service.get_status()["is_running"] is False
@pytest.mark.asyncio
async def test_status_includes_cron_fields(self, scheduler_service):
"""get_status() includes schedule_time, schedule_days, auto_download keys."""
await scheduler_service.start()
status = scheduler_service.get_status()
for key in ("schedule_time", "schedule_days", "auto_download_after_rescan", "next_run"):
assert key in status
# ---------------------------------------------------------------------------
# TestReloadConfig
# ---------------------------------------------------------------------------
class TestReloadConfig:
"""Tests for reload_config() live reconfiguration."""
@pytest.mark.asyncio
async def test_reload_reschedules_job_on_time_change(self, scheduler_service):
"""Changing schedule_time reschedules the existing job."""
await scheduler_service.start()
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
new_config = SchedulerConfig(enabled=True, schedule_time="08:00")
scheduler_service.reload_config(new_config)
job = scheduler_service._scheduler.get_job(_JOB_ID)
assert job is not None
assert scheduler_service._config.schedule_time == "08:00"
@pytest.mark.asyncio
async def test_reload_removes_job_when_disabled(self, scheduler_service):
"""Setting enabled=False removes the APScheduler job."""
await scheduler_service.start()
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
scheduler_service.reload_config(
SchedulerConfig(enabled=False)
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
@pytest.mark.asyncio
async def test_reload_removes_job_when_days_empty(self, scheduler_service):
"""Empty schedule_days removes the APScheduler job."""
await scheduler_service.start()
scheduler_service.reload_config(
SchedulerConfig(enabled=True, schedule_days=[])
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
@pytest.mark.asyncio
async def test_reload_adds_job_when_reenabling(self, scheduler_service):
"""Re-enabling after disable adds a new job."""
await scheduler_service.start()
scheduler_service.reload_config(SchedulerConfig(enabled=False))
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
scheduler_service.reload_config(
SchedulerConfig(enabled=True, schedule_time="09:00")
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
@pytest.mark.asyncio
async def test_reload_updates_config_attribute(self, scheduler_service):
"""reload_config() updates self._config with the supplied instance."""
await scheduler_service.start()
new = SchedulerConfig(enabled=True, schedule_time="14:30", schedule_days=["mon"])
scheduler_service.reload_config(new)
assert scheduler_service._config.schedule_time == "14:30"
assert scheduler_service._config.schedule_days == ["mon"]
def test_reload_before_start_stores_config(self, scheduler_service):
"""reload_config() before start() stores config without raising."""
new = SchedulerConfig(enabled=True, schedule_time="22:00")
scheduler_service.reload_config(new)
assert scheduler_service._config.schedule_time == "22:00"
# ---------------------------------------------------------------------------
# TestAutoDownloadWorkflow
# ---------------------------------------------------------------------------
class TestAutoDownloadWorkflow:
"""Tests for auto-download-after-rescan integration."""
@pytest.mark.asyncio
async def test_auto_download_triggered_when_enabled(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_auto_download_missing() is called when auto_download_after_rescan=True."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=True,
)
scheduler_service._is_running = True
called = []
async def fake_auto_download():
called.append(True)
scheduler_service._auto_download_missing = fake_auto_download
await scheduler_service._perform_rescan()
assert called == [True]
@pytest.mark.asyncio
async def test_auto_download_not_called_when_disabled(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_auto_download_missing() is NOT called when auto_download_after_rescan=False."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=False,
)
scheduler_service._is_running = True
called = []
async def fake_auto_download():
called.append(True)
scheduler_service._auto_download_missing = fake_auto_download
await scheduler_service._perform_rescan()
assert called == []
@pytest.mark.asyncio
async def test_auto_download_error_broadcasts_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Error in _auto_download_missing broadcasts 'auto_download_error'."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=True,
)
scheduler_service._is_running = True
async def failing_auto_download():
raise RuntimeError("download failed")
scheduler_service._auto_download_missing = failing_auto_download
await scheduler_service._perform_rescan()
error_events = [
b for b in mock_websocket_service.broadcasts
if b["type"] == "auto_download_error"
]
assert len(error_events) == 1
# ---------------------------------------------------------------------------
# TestSchedulerSingletonHelpers
# ---------------------------------------------------------------------------
class TestSchedulerSingletonHelpers:
"""Tests for module-level singleton helpers."""
def test_get_scheduler_service_returns_same_instance(self):
"""get_scheduler_service() returns the same object on repeated calls."""
svc1 = get_scheduler_service()
svc2 = get_scheduler_service()
assert svc1 is svc2
def test_reset_clears_singleton(self):
"""reset_scheduler_service() causes get_scheduler_service() to return a new instance."""
svc1 = get_scheduler_service()
reset_scheduler_service()
svc2 = get_scheduler_service()
assert svc1 is not svc2
@pytest.mark.asyncio
async def test_state_persists_across_restart(self, mock_config_service):
"""Stopping and restarting loads config from service each time."""
reset_scheduler_service()
svc = SchedulerService()
await svc.start()
original_time = svc._config.schedule_time
assert svc._is_running is True
await svc.stop()
assert svc._is_running is False
reset_scheduler_service()
svc2 = SchedulerService()
await svc2.start()
assert svc2._is_running is True
assert svc2._config.schedule_time == original_time
await svc2.stop()