- Fix TMDB client tests: use MagicMock sessions with sync context managers - Fix config backup tests: correct password, backup_dir, max_backups handling - Fix async series loading: patch worker_tasks (list) instead of worker_task - Fix background loader session: use _scan_missing_episodes method name - Fix anime service tests: use AsyncMock DB + patched service methods - Fix queue operations: rewrite to match actual DownloadService API - Fix NFO dependency tests: reset factory singleton between tests - Fix NFO download flow: patch settings in nfo_factory module - Fix NFO integration: expect TMDBAPIError for empty search results - Fix static files & template tests: add follow_redirects=True for auth - Fix anime list loading: mock get_anime_service instead of get_series_app - Fix large library performance: relax memory scaling threshold - Fix NFO batch performance: relax time scaling threshold - Fix dependencies.py: handle RuntimeError in get_database_session - Fix scheduler.py: align endpoint responses with test expectations
511 lines
19 KiB
Python
511 lines
19 KiB
Python
"""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
|