"""Unit tests for NFO service.""" from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.core.services.nfo_service import NFOService from src.core.services.tmdb_client import TMDBAPIError @pytest.fixture def nfo_service(tmp_path): """Create NFO service with test directory.""" service = NFOService( tmdb_api_key="test_api_key", anime_directory=str(tmp_path), image_size="w500", auto_create=True ) return service @pytest.fixture def mock_tmdb_data(): """Mock TMDB API response data.""" return { "id": 1429, "name": "Attack on Titan", "original_name": "進撃の巨人", "first_air_date": "2013-04-07", "overview": "Several hundred years ago, humans were nearly...", "vote_average": 8.6, "vote_count": 5000, "status": "Ended", "episode_run_time": [24], "genres": [{"id": 16, "name": "Animation"}, {"id": 10765, "name": "Sci-Fi & Fantasy"}], "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 Yeager", "profile_path": "/actor.jpg" } ] }, "images": { "logos": [{"file_path": "/logo.png"}] } } @pytest.fixture def mock_content_ratings_de(): """Mock TMDB content ratings with German FSK rating.""" return { "results": [ {"iso_3166_1": "DE", "rating": "16"}, {"iso_3166_1": "US", "rating": "TV-MA"} ] } @pytest.fixture def mock_content_ratings_no_de(): """Mock TMDB content ratings without German rating.""" return { "results": [ {"iso_3166_1": "US", "rating": "TV-MA"}, {"iso_3166_1": "GB", "rating": "15"} ] } class TestFSKRatingExtraction: """Test FSK rating extraction from TMDB content ratings.""" def test_extract_fsk_rating_de(self, nfo_service, mock_content_ratings_de): """Test extraction of German FSK rating.""" fsk = nfo_service._extract_fsk_rating(mock_content_ratings_de) assert fsk == "FSK 16" def test_extract_fsk_rating_no_de(self, nfo_service, mock_content_ratings_no_de): """Test extraction when no German rating available.""" fsk = nfo_service._extract_fsk_rating(mock_content_ratings_no_de) assert fsk is None def test_extract_fsk_rating_empty(self, nfo_service): """Test extraction with empty content ratings.""" fsk = nfo_service._extract_fsk_rating({}) assert fsk is None def test_extract_fsk_rating_none(self, nfo_service): """Test extraction with None input.""" fsk = nfo_service._extract_fsk_rating(None) assert fsk is None def test_extract_fsk_all_values(self, nfo_service): """Test extraction of all FSK values.""" fsk_mappings = { "0": "FSK 0", "6": "FSK 6", "12": "FSK 12", "16": "FSK 16", "18": "FSK 18" } for rating_value, expected_fsk in fsk_mappings.items(): content_ratings = { "results": [{"iso_3166_1": "DE", "rating": rating_value}] } fsk = nfo_service._extract_fsk_rating(content_ratings) assert fsk == expected_fsk def test_extract_fsk_already_formatted(self, nfo_service): """Test extraction when rating is already in FSK format.""" content_ratings = { "results": [{"iso_3166_1": "DE", "rating": "FSK 12"}] } fsk = nfo_service._extract_fsk_rating(content_ratings) assert fsk == "FSK 12" def test_extract_fsk_partial_match(self, nfo_service): """Test extraction with partial number match.""" content_ratings = { "results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}] } fsk = nfo_service._extract_fsk_rating(content_ratings) assert fsk == "FSK 16" def test_extract_fsk_unmapped_value(self, nfo_service): """Test extraction with unmapped rating value.""" content_ratings = { "results": [{"iso_3166_1": "DE", "rating": "Unknown"}] } fsk = nfo_service._extract_fsk_rating(content_ratings) assert fsk is None class TestYearExtraction: """Test year extraction from series names.""" def test_extract_year_with_year(self, nfo_service): """Test extraction when year is present in format (YYYY).""" clean_name, year = nfo_service._extract_year_from_name("Attack on Titan (2013)") assert clean_name == "Attack on Titan" assert year == 2013 def test_extract_year_without_year(self, nfo_service): """Test extraction when no year is present.""" clean_name, year = nfo_service._extract_year_from_name("Attack on Titan") assert clean_name == "Attack on Titan" assert year is None def test_extract_year_multiple_parentheses(self, nfo_service): """Test extraction with multiple parentheses - only last one with year.""" clean_name, year = nfo_service._extract_year_from_name("Series (Part 1) (2023)") assert clean_name == "Series (Part 1)" assert year == 2023 def test_extract_year_with_trailing_spaces(self, nfo_service): """Test extraction with trailing spaces.""" clean_name, year = nfo_service._extract_year_from_name("Attack on Titan (2013) ") assert clean_name == "Attack on Titan" assert year == 2013 def test_extract_year_parentheses_not_year(self, nfo_service): """Test extraction when parentheses don't contain a year.""" clean_name, year = nfo_service._extract_year_from_name("Series (Special Edition)") assert clean_name == "Series (Special Edition)" assert year is None def test_extract_year_invalid_year_format(self, nfo_service): """Test extraction with invalid year format (not 4 digits).""" clean_name, year = nfo_service._extract_year_from_name("Series (23)") assert clean_name == "Series (23)" assert year is None def test_extract_year_future_year(self, nfo_service): """Test extraction with future year.""" clean_name, year = nfo_service._extract_year_from_name("Future Series (2050)") assert clean_name == "Future Series" assert year == 2050 def test_extract_year_old_year(self, nfo_service): """Test extraction with old year.""" clean_name, year = nfo_service._extract_year_from_name("Classic Series (1990)") assert clean_name == "Classic Series" assert year == 1990 def test_extract_year_real_world_example(self, nfo_service): """Test extraction with the real-world example from the bug report.""" clean_name, year = nfo_service._extract_year_from_name("The Dreaming Boy is a Realist (2023)") assert clean_name == "The Dreaming Boy is a Realist" assert year == 2023 def test_extract_year_uebel_blatt(self, nfo_service): """Test extraction with Übel Blatt example.""" clean_name, year = nfo_service._extract_year_from_name("Übel Blatt (2025)") assert clean_name == "Übel Blatt" assert year == 2025 class TestTMDBToNFOModel: """Test conversion of TMDB data to NFO model.""" @patch.object(NFOService, '_extract_fsk_rating') def test_tmdb_to_nfo_with_fsk(self, mock_extract_fsk, nfo_service, mock_tmdb_data, mock_content_ratings_de): """Test conversion includes FSK rating.""" mock_extract_fsk.return_value = "FSK 16" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data, mock_content_ratings_de) assert nfo_model.title == "Attack on Titan" assert nfo_model.fsk == "FSK 16" assert nfo_model.year == 2013 mock_extract_fsk.assert_called_once_with(mock_content_ratings_de) def test_tmdb_to_nfo_without_content_ratings(self, nfo_service, mock_tmdb_data): """Test conversion without content ratings.""" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data, None) assert nfo_model.title == "Attack on Titan" assert nfo_model.fsk is None assert nfo_model.tmdbid == 1429 def test_tmdb_to_nfo_basic_fields(self, nfo_service, mock_tmdb_data): """Test that all basic fields are correctly mapped.""" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) assert nfo_model.title == "Attack on Titan" assert nfo_model.originaltitle == "進撃の巨人" assert nfo_model.year == 2013 assert nfo_model.plot == "Several hundred years ago, humans were nearly..." assert nfo_model.status == "Ended" assert nfo_model.runtime == 24 assert nfo_model.premiered == "2013-04-07" def test_tmdb_to_nfo_ids(self, nfo_service, mock_tmdb_data): """Test that all IDs are correctly mapped.""" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) assert nfo_model.tmdbid == 1429 assert nfo_model.imdbid == "tt2560140" assert nfo_model.tvdbid == 267440 assert len(nfo_model.uniqueid) == 3 def test_tmdb_to_nfo_genres_studios(self, nfo_service, mock_tmdb_data): """Test that genres and studios are correctly mapped.""" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) assert "Animation" in nfo_model.genre assert "Sci-Fi & Fantasy" in nfo_model.genre assert "MBS" in nfo_model.studio assert "Japan" in nfo_model.country def test_tmdb_to_nfo_ratings(self, nfo_service, mock_tmdb_data): """Test that ratings are correctly mapped.""" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) assert len(nfo_model.ratings) == 1 assert nfo_model.ratings[0].name == "themoviedb" assert nfo_model.ratings[0].value == 8.6 assert nfo_model.ratings[0].votes == 5000 def test_tmdb_to_nfo_cast(self, nfo_service, mock_tmdb_data): """Test that cast is correctly mapped.""" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) assert len(nfo_model.actors) == 1 assert nfo_model.actors[0].name == "Yuki Kaji" assert nfo_model.actors[0].role == "Eren Yeager" assert nfo_model.actors[0].tmdbid == 1 class TestCreateTVShowNFO: """Test NFO creation workflow.""" @pytest.mark.asyncio async def test_create_nfo_with_year_in_name(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): """Test NFO creation when year is included in series name. This test addresses the bug where searching TMDB with year in the name (e.g., "The Dreaming Boy is a Realist (2023)") fails to find results. """ # Setup serie_name = "The Dreaming Boy is a Realist (2023)" serie_folder = "The Dreaming Boy is a Realist (2023)" (tmp_path / serie_folder).mkdir() # Mock TMDB responses search_results = {"results": [mock_tmdb_data]} with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details: with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings: with patch.object(nfo_service.image_downloader, 'download_poster', new_callable=AsyncMock): with patch.object(nfo_service.image_downloader, 'download_logo', new_callable=AsyncMock): with patch.object(nfo_service.image_downloader, 'download_fanart', new_callable=AsyncMock): mock_search.return_value = search_results mock_details.return_value = mock_tmdb_data mock_ratings.return_value = mock_content_ratings_de # Act nfo_path = await nfo_service.create_tvshow_nfo( serie_name=serie_name, serie_folder=serie_folder, year=None # Year should be auto-extracted ) # Assert - should search with clean name "The Dreaming Boy is a Realist" mock_search.assert_called_once_with("The Dreaming Boy is a Realist") # Verify NFO file was created assert nfo_path.exists() assert nfo_path.name == "tvshow.nfo" @pytest.mark.asyncio async def test_create_nfo_year_parameter_takes_precedence(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): """Test that explicit year parameter takes precedence over extracted year.""" # Setup serie_name = "Attack on Titan (2013)" serie_folder = "Attack on Titan" explicit_year = 2015 # Different from extracted year (tmp_path / serie_folder).mkdir() # Mock TMDB responses search_results = {"results": [mock_tmdb_data]} with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details: with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings: with patch.object(nfo_service.image_downloader, 'download_poster', new_callable=AsyncMock): with patch.object(nfo_service.image_downloader, 'download_logo', new_callable=AsyncMock): with patch.object(nfo_service.image_downloader, 'download_fanart', new_callable=AsyncMock): with patch.object(nfo_service, '_find_best_match') as mock_find_match: mock_search.return_value = search_results mock_details.return_value = mock_tmdb_data mock_ratings.return_value = mock_content_ratings_de mock_find_match.return_value = mock_tmdb_data # Act await nfo_service.create_tvshow_nfo( serie_name=serie_name, serie_folder=serie_folder, year=explicit_year # Explicit year provided ) # Assert - should use explicit year, not extracted year mock_find_match.assert_called_once() call_args = mock_find_match.call_args assert call_args[0][2] == explicit_year # Third argument is year @pytest.mark.asyncio async def test_create_nfo_no_results_with_clean_name(self, nfo_service, tmp_path): """Test error handling when TMDB returns no results even with clean name.""" # Setup serie_name = "Nonexistent Series (2023)" serie_folder = "Nonexistent Series (2023)" (tmp_path / serie_folder).mkdir() with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: mock_search.return_value = {"results": []} # Act & Assert with pytest.raises(TMDBAPIError) as exc_info: await nfo_service.create_tvshow_nfo( serie_name=serie_name, serie_folder=serie_folder ) # Should use clean name in error message assert "No results found for: Nonexistent Series" in str(exc_info.value) # Should have searched with clean name mock_search.assert_called_once_with("Nonexistent Series") @pytest.mark.asyncio async def test_create_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): """Test NFO creation includes FSK rating.""" # Create series folder series_folder = tmp_path / "Attack on Titan" series_folder.mkdir() # Mock TMDB client methods 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, '_download_media_files', new_callable=AsyncMock): mock_search.return_value = {"results": [{"id": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]} mock_details.return_value = mock_tmdb_data mock_ratings.return_value = mock_content_ratings_de # Create NFO nfo_path = await nfo_service.create_tvshow_nfo( "Attack on Titan", "Attack on Titan", year=2013, download_poster=False, download_logo=False, download_fanart=False ) # Verify NFO was created assert nfo_path.exists() nfo_content = nfo_path.read_text(encoding="utf-8") # Check that FSK rating is in the NFO assert "FSK 16" in nfo_content # Verify TMDB methods were called mock_search.assert_called_once() mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images") mock_ratings.assert_called_once_with(1429) @pytest.mark.asyncio async def test_create_nfo_without_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_no_de): """Test NFO creation fallback when no FSK available.""" # Create series folder series_folder = tmp_path / "Attack on Titan" series_folder.mkdir() # Mock TMDB client methods 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, '_download_media_files', new_callable=AsyncMock): mock_search.return_value = {"results": [{"id": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]} mock_details.return_value = mock_tmdb_data mock_ratings.return_value = mock_content_ratings_no_de # Create NFO nfo_path = await nfo_service.create_tvshow_nfo( "Attack on Titan", "Attack on Titan", year=2013, download_poster=False, download_logo=False, download_fanart=False ) # Verify NFO was created assert nfo_path.exists() nfo_content = nfo_path.read_text(encoding="utf-8") # FSK should not be in the NFO assert "FSK" not in nfo_content @pytest.mark.asyncio async def test_update_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): """Test NFO update includes FSK rating.""" # Create series folder with existing NFO series_folder = tmp_path / "Attack on Titan" series_folder.mkdir() nfo_path = series_folder / "tvshow.nfo" nfo_path.write_text(""" Attack on Titan 1429 """, encoding="utf-8") # Mock TMDB client methods 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, '_download_media_files', new_callable=AsyncMock): mock_details.return_value = mock_tmdb_data mock_ratings.return_value = mock_content_ratings_de # Update NFO updated_path = await nfo_service.update_tvshow_nfo( "Attack on Titan", download_media=False ) # Verify NFO was updated assert updated_path.exists() nfo_content = updated_path.read_text(encoding="utf-8") # Check that FSK rating is in the updated NFO assert "FSK 16" in nfo_content # Verify TMDB methods were called mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images") mock_ratings.assert_called_once_with(1429) class TestNFOServiceEdgeCases: """Test edge cases in NFO service.""" @pytest.mark.asyncio async def test_create_nfo_with_valid_path(self, nfo_service, tmp_path): """Test NFO creation succeeds with valid path.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() # Mock all necessary TMDB client methods 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, '_download_media_files', new_callable=AsyncMock): tmdb_data = { "id": 1, "name": "Series", "first_air_date": "2020-01-01", "original_name": "Original", "overview": "Test", "vote_average": 8.0, "vote_count": 100, "status": "Continuing", "episode_run_time": [24], "genres": [], "networks": [], "production_countries": [], "external_ids": {}, "credits": {"cast": []}, "images": {"logos": []}, "poster_path": None, "backdrop_path": None } mock_search.return_value = {"results": [{"id": 1, "name": "Series", "first_air_date": "2020-01-01"}]} mock_details.return_value = tmdb_data mock_ratings.return_value = {"results": []} nfo_path = await nfo_service.create_tvshow_nfo( "Test Series", "Test Series", download_poster=False, download_logo=False, download_fanart=False ) assert nfo_path.exists() @pytest.mark.asyncio async def test_create_nfo_no_tmdb_results(self, nfo_service, tmp_path): """Test NFO creation when TMDB returns no results.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: mock_search.return_value = {"results": []} with pytest.raises(TMDBAPIError, match="No results found"): await nfo_service.create_tvshow_nfo( "Test Series", "Test Series", download_poster=False, download_logo=False, download_fanart=False ) @pytest.mark.asyncio async def test_update_nfo_missing_nfo(self, nfo_service, tmp_path): """Test NFO update when NFO doesn't exist.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() with pytest.raises(FileNotFoundError): await nfo_service.update_tvshow_nfo("Test Series") @pytest.mark.asyncio async def test_update_nfo_no_tmdb_id(self, nfo_service, tmp_path): """Test NFO update when NFO has no TMDB ID.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() nfo_path = series_folder / "tvshow.nfo" nfo_path.write_text(""" Test Series """, encoding="utf-8") with pytest.raises(TMDBAPIError, match="No TMDB ID found"): await nfo_service.update_tvshow_nfo("Test Series") @pytest.mark.asyncio async def test_check_nfo_exists(self, nfo_service, tmp_path): """Test checking if NFO exists.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() # NFO doesn't exist yet exists = await nfo_service.check_nfo_exists("Test Series") assert not exists # Create NFO nfo_path = series_folder / "tvshow.nfo" nfo_path.write_text("", encoding="utf-8") # NFO now exists exists = await nfo_service.check_nfo_exists("Test Series") assert exists class TestMediaDownloads: """Test media file (poster, logo, fanart) download functionality.""" @pytest.mark.asyncio async def test_download_media_all_enabled(self, nfo_service, tmp_path, mock_tmdb_data): """Test downloading all media files when enabled.""" series_folder = tmp_path / "Test Series" 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 } results = await nfo_service._download_media_files( mock_tmdb_data, series_folder, download_poster=True, download_logo=True, download_fanart=True ) assert results["poster"] is True assert results["logo"] is True assert results["fanart"] is True mock_download.assert_called_once() @pytest.mark.asyncio async def test_download_media_poster_only(self, nfo_service, tmp_path, mock_tmdb_data): """Test downloading only poster.""" series_folder = tmp_path / "Test Series" 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} results = await nfo_service._download_media_files( mock_tmdb_data, series_folder, download_poster=True, download_logo=False, download_fanart=False ) # Verify only poster URL was passed call_args = mock_download.call_args assert call_args.kwargs['poster_url'] is not None assert call_args.kwargs['logo_url'] is None assert call_args.kwargs['fanart_url'] is None @pytest.mark.asyncio async def test_download_media_with_image_size(self, nfo_service, tmp_path, mock_tmdb_data): """Test that image size configuration is used.""" nfo_service.image_size = "w500" series_folder = tmp_path / "Test Series" 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} await nfo_service._download_media_files( mock_tmdb_data, series_folder, download_poster=True, download_logo=False, download_fanart=False ) # Verify image size was used for poster call_args = mock_download.call_args poster_url = call_args.kwargs['poster_url'] assert "w500" in poster_url @pytest.mark.asyncio async def test_download_media_missing_poster_path(self, nfo_service, tmp_path): """Test media download when poster path is missing.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() tmdb_data_no_poster = { "id": 1, "name": "Test", "poster_path": None, "backdrop_path": "/backdrop.jpg", "images": {"logos": [{"file_path": "/logo.png"}]} } with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: mock_download.return_value = {} await nfo_service._download_media_files( tmdb_data_no_poster, series_folder, download_poster=True, download_logo=True, download_fanart=True ) # Poster URL should be None call_args = mock_download.call_args assert call_args.kwargs['poster_url'] is None @pytest.mark.asyncio async def test_download_media_no_logo_available(self, nfo_service, tmp_path): """Test media download when logo is not available.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() tmdb_data_no_logo = { "id": 1, "name": "Test", "poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg", "images": {"logos": []} # Empty logos array } with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: mock_download.return_value = {"poster": True, "fanart": True} await nfo_service._download_media_files( tmdb_data_no_logo, series_folder, download_poster=True, download_logo=True, download_fanart=True ) # Logo URL should be None call_args = mock_download.call_args assert call_args.kwargs['logo_url'] is None @pytest.mark.asyncio async def test_download_media_all_disabled(self, nfo_service, tmp_path, mock_tmdb_data): """Test that no downloads occur when all disabled.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: mock_download.return_value = {} await nfo_service._download_media_files( mock_tmdb_data, series_folder, download_poster=False, download_logo=False, download_fanart=False ) # All URLs should be None 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_download_media_fanart_uses_original_size(self, nfo_service, tmp_path, mock_tmdb_data): """Test that fanart always uses original size regardless of config.""" nfo_service.image_size = "w500" series_folder = tmp_path / "Test Series" series_folder.mkdir() with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: mock_download.return_value = {"fanart": True} await nfo_service._download_media_files( mock_tmdb_data, series_folder, download_poster=False, download_logo=False, download_fanart=True ) # Fanart should use original size call_args = mock_download.call_args fanart_url = call_args.kwargs['fanart_url'] assert "original" in fanart_url @pytest.mark.asyncio async def test_download_media_logo_uses_original_size(self, nfo_service, tmp_path, mock_tmdb_data): """Test that logo always uses original size.""" nfo_service.image_size = "w500" series_folder = tmp_path / "Test Series" series_folder.mkdir() with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: mock_download.return_value = {"logo": True} await nfo_service._download_media_files( mock_tmdb_data, series_folder, download_poster=False, download_logo=True, download_fanart=False ) # Logo should use original size call_args = mock_download.call_args logo_url = call_args.kwargs['logo_url'] assert "original" in logo_url class TestNFOServiceConfiguration: """Test NFO service with various configuration settings.""" def test_nfo_service_default_config(self, tmp_path): """Test NFO service initialization with default config.""" service = NFOService( tmdb_api_key="test_key", anime_directory=str(tmp_path) ) assert service.image_size == "original" assert service.auto_create is True def test_nfo_service_custom_config(self, tmp_path): """Test NFO service initialization with custom config.""" service = NFOService( tmdb_api_key="test_key", anime_directory=str(tmp_path), image_size="w500", auto_create=False ) assert service.image_size == "w500" assert service.auto_create is False def test_nfo_service_image_sizes(self, tmp_path): """Test NFO service with various image sizes.""" sizes = ["original", "w500", "w780", "w342"] for size in sizes: service = NFOService( tmdb_api_key="test_key", anime_directory=str(tmp_path), image_size=size ) assert service.image_size == size class TestHasNFOMethod: """Test the has_nfo method.""" def test_has_nfo_true(self, nfo_service, tmp_path): """Test has_nfo returns True when NFO exists.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() nfo_path = series_folder / "tvshow.nfo" nfo_path.write_text("") assert nfo_service.has_nfo("Test Series") is True def test_has_nfo_false(self, nfo_service, tmp_path): """Test has_nfo returns False when NFO doesn't exist.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() assert nfo_service.has_nfo("Test Series") is False def test_has_nfo_missing_folder(self, nfo_service): """Test has_nfo returns False when folder doesn't exist.""" assert nfo_service.has_nfo("Nonexistent Series") is False class TestFindBestMatchEdgeCases: """Test edge cases in _find_best_match.""" def test_find_best_match_no_year_multiple_results(self, nfo_service): """Test finding best match returns first result when no year.""" results = [ {"id": 1, "name": "Series", "first_air_date": "2010-01-01"}, {"id": 2, "name": "Series", "first_air_date": "2020-01-01"}, ] match = nfo_service._find_best_match(results, "Series", year=None) assert match["id"] == 1 def test_find_best_match_year_no_match(self, nfo_service): """Test finding best match with year when no exact match returns first.""" results = [ {"id": 1, "name": "Series", "first_air_date": "2010-01-01"}, {"id": 2, "name": "Series", "first_air_date": "2020-01-01"}, ] match = nfo_service._find_best_match(results, "Series", year=2025) # Should return first result as no year match found assert match["id"] == 1 def test_find_best_match_empty_results(self, nfo_service): """Test finding best match with empty results raises error.""" with pytest.raises(TMDBAPIError, match="No search results"): nfo_service._find_best_match([], "Series") def test_find_best_match_no_first_air_date(self, nfo_service): """Test finding best match when result has no first_air_date.""" results = [ {"id": 1, "name": "Series"}, # No first_air_date {"id": 2, "name": "Series", "first_air_date": "2020-01-01"}, ] # With year, should check for first_air_date existence match = nfo_service._find_best_match(results, "Series", year=2020) assert match["id"] == 2 class TestParseNFOIDsEdgeCases: """Test edge cases in parse_nfo_ids.""" def test_parse_nfo_ids_malformed_ids(self, nfo_service, tmp_path): """Test parsing IDs with malformed values.""" nfo_path = tmp_path / "tvshow.nfo" nfo_path.write_text( '' 'not_a_number' 'abc123' '' ) ids = nfo_service.parse_nfo_ids(nfo_path) # Malformed values should be None assert ids["tmdb_id"] is None assert ids["tvdb_id"] is None def test_parse_nfo_ids_multiple_uniqueid(self, nfo_service, tmp_path): """Test parsing when multiple uniqueid elements exist.""" nfo_path = tmp_path / "tvshow.nfo" nfo_path.write_text( '' '1429' '79168' 'tt2560140' '' ) ids = nfo_service.parse_nfo_ids(nfo_path) assert ids["tmdb_id"] == 1429 assert ids["tvdb_id"] == 79168 def test_parse_nfo_ids_empty_uniqueid(self, nfo_service, tmp_path): """Test parsing with empty uniqueid elements.""" nfo_path = tmp_path / "tvshow.nfo" nfo_path.write_text( '' '' '' '' ) ids = nfo_service.parse_nfo_ids(nfo_path) assert ids["tmdb_id"] is None assert ids["tvdb_id"] is None class TestTMDBToNFOModelEdgeCases: """Test edge cases in _tmdb_to_nfo_model.""" def test_tmdb_to_nfo_minimal_data(self, nfo_service): """Test conversion with minimal TMDB data.""" minimal_data = { "id": 1, "name": "Series", "original_name": "Original" } nfo_model = nfo_service._tmdb_to_nfo_model(minimal_data) assert nfo_model.title == "Series" assert nfo_model.originaltitle == "Original" assert nfo_model.year is None assert nfo_model.tmdbid == 1 def test_tmdb_to_nfo_with_all_cast(self, nfo_service, mock_tmdb_data): """Test conversion includes cast members.""" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) assert len(nfo_model.actors) >= 1 assert nfo_model.actors[0].name == "Yuki Kaji" assert nfo_model.actors[0].role == "Eren Yeager" def test_tmdb_to_nfo_multiple_genres(self, nfo_service, mock_tmdb_data): """Test conversion with multiple genres.""" nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) assert "Animation" in nfo_model.genre assert "Sci-Fi & Fantasy" in nfo_model.genre class TestExtractFSKRatingEdgeCases: """Test edge cases in _extract_fsk_rating.""" def test_extract_fsk_with_suffix(self, nfo_service): """Test extraction when rating has suffix like 'Ab 16 Jahren'.""" content_ratings = { "results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}] } fsk = nfo_service._extract_fsk_rating(content_ratings) assert fsk == "FSK 16" def test_extract_fsk_multiple_numbers(self, nfo_service): """Test extraction with multiple numbers - should pick highest.""" content_ratings = { "results": [{"iso_3166_1": "DE", "rating": "Rating 6 or 12"}] } fsk = nfo_service._extract_fsk_rating(content_ratings) # Should find 12 first in the search order assert fsk == "FSK 12" def test_extract_fsk_empty_results_list(self, nfo_service): """Test extraction with empty results list.""" content_ratings = {"results": []} fsk = nfo_service._extract_fsk_rating(content_ratings) assert fsk is None def test_extract_fsk_none_input(self, nfo_service): """Test extraction with None input.""" fsk = nfo_service._extract_fsk_rating(None) assert fsk is None def test_extract_fsk_missing_results_key(self, nfo_service): """Test extraction when results key is missing.""" fsk = nfo_service._extract_fsk_rating({}) assert fsk is None class TestDownloadMediaFilesEdgeCases: """Test edge cases in _download_media_files.""" @pytest.mark.asyncio async def test_download_media_empty_tmdb_data(self, nfo_service, tmp_path): """Test media download with empty TMDB data.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: mock_download.return_value = {} results = await nfo_service._download_media_files( {}, series_folder, download_poster=True, download_logo=True, download_fanart=True ) # Should call download with all None URLs 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_download_media_only_poster_available(self, nfo_service, tmp_path): """Test media download when only poster is available.""" series_folder = tmp_path / "Test Series" series_folder.mkdir() tmdb_data = { "id": 1, "poster_path": "/poster.jpg", "backdrop_path": None, "images": {"logos": []} } with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: mock_download.return_value = {"poster": True} await nfo_service._download_media_files( tmdb_data, series_folder, download_poster=True, download_logo=True, download_fanart=True ) call_args = mock_download.call_args assert call_args.kwargs['poster_url'] is not None assert call_args.kwargs['fanart_url'] is None assert call_args.kwargs['logo_url'] is None class TestUpdateNFOEdgeCases: """Test edge cases in update_tvshow_nfo.""" @pytest.mark.asyncio async def test_update_nfo_without_media_download(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): """Test NFO update without re-downloading media.""" series_folder = tmp_path / "Attack on Titan" series_folder.mkdir() nfo_path = series_folder / "tvshow.nfo" nfo_path.write_text( '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, '_download_media_files', new_callable=AsyncMock) as mock_download: mock_details.return_value = mock_tmdb_data mock_ratings.return_value = mock_content_ratings_de await nfo_service.update_tvshow_nfo("Attack on Titan", download_media=False) # Verify download was not called mock_download.assert_not_called() class TestNFOServiceClose: """Test NFO service cleanup and close.""" @pytest.mark.asyncio async def test_nfo_service_close(self, nfo_service): """Test NFO service close.""" with patch.object(nfo_service.tmdb_client, 'close', new_callable=AsyncMock) as mock_close: await nfo_service.close() mock_close.assert_called_once() class TestYearExtractionComprehensive: """Comprehensive tests for year extraction.""" def test_extract_year_with_leading_spaces(self, nfo_service): """Test extraction with leading spaces - they get stripped.""" clean_name, year = nfo_service._extract_year_from_name(" Attack on Titan (2013)") assert clean_name == "Attack on Titan" # Leading spaces are stripped assert year == 2013 def test_extract_year_with_year_in_middle(self, nfo_service): """Test that year in middle doesn't get extracted.""" clean_name, year = nfo_service._extract_year_from_name("Attack on Titan 2013") assert clean_name == "Attack on Titan 2013" assert year is None def test_extract_year_three_digit(self, nfo_service): """Test that 3-digit number is not extracted.""" clean_name, year = nfo_service._extract_year_from_name("Series (123)") assert clean_name == "Series (123)" assert year is None def test_extract_year_five_digit(self, nfo_service): """Test that 5-digit number is not extracted.""" clean_name, year = nfo_service._extract_year_from_name("Series (12345)") assert clean_name == "Series (12345)" assert year is None