"""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