"""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 "Attack on Titan" in nfo_content assert "FSK 16" in nfo_content assert "1429" 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(""" Old Title Old plot 1429 """, 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 "Attack on Titan" 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(""" Test 1429 """, 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 "" 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 folder.""" with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock): with pytest.raises(FileNotFoundError): 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 "Attack on Titan" in nfo_content assert "進撃の巨人" in nfo_content assert "2013" in nfo_content assert "Humans fight against giant humanoid Titans." in nfo_content assert "Ended" in nfo_content assert "Animation" in nfo_content assert "MBS" in nfo_content assert "Japan" in nfo_content assert "FSK 16" in nfo_content assert "1429" in nfo_content assert "tt2560140" in nfo_content assert "267440" in nfo_content assert "Yuki Kaji" in nfo_content assert "Eren" in nfo_content