diff --git a/tests/integration/test_add_anime_nfo_content.py b/tests/integration/test_add_anime_nfo_content.py new file mode 100644 index 0000000..012a526 --- /dev/null +++ b/tests/integration/test_add_anime_nfo_content.py @@ -0,0 +1,314 @@ +"""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, " tag missing from NFO" + assert outline_elem is not None, " tag missing from NFO" + + plot_text = (plot_elem.text or "").strip() + outline_text = (outline_elem.text or "").strip() + + assert plot_text, " tag is empty" + assert outline_text, " 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}" + ) diff --git a/tests/integration/test_sacrificial_princess_nfo.py b/tests/integration/test_sacrificial_princess_nfo.py new file mode 100644 index 0000000..d306ba1 --- /dev/null +++ b/tests/integration/test_sacrificial_princess_nfo.py @@ -0,0 +1,429 @@ +"""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 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}\n" + "\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( + '\n' + "\n" + f" {SERIES_NAME}\n" + f" {TMDB_ID}\n" + "\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 , 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( + '\n' + "\n" + f" {SERIES_NAME}\n" + "\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