From ddcac5a96d7d562d781b6248ff2d6a54658dd428 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 22 Feb 2026 17:01:31 +0100 Subject: [PATCH] test: add live TMDB integration tests for NFO creation and repair --- tests/integration/test_nfo_live_tmdb.py | 272 ++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 tests/integration/test_nfo_live_tmdb.py diff --git a/tests/integration/test_nfo_live_tmdb.py b/tests/integration/test_nfo_live_tmdb.py new file mode 100644 index 0000000..e8a62ce --- /dev/null +++ b/tests/integration/test_nfo_live_tmdb.py @@ -0,0 +1,272 @@ +"""Live integration tests for NFO creation and update using real TMDB data. + +These tests call the real TMDB API and verify the complete NFO pipeline for +86: Eighty Six (TMDB 100565 / IMDB tt13718450 / TVDB 378609). + +Run with: + conda run -n AniWorld python -m pytest tests/integration/test_nfo_live_tmdb.py -v --tb=short +""" + +import asyncio +from pathlib import Path + +import pytest +from lxml import etree + +from src.core.services.nfo_service import NFOService + +# --------------------------------------------------------------------------- +# Show identity constants +# --------------------------------------------------------------------------- +TMDB_ID = 100565 +IMDB_ID = "tt13718450" +TVDB_ID = 378609 +SHOW_NAME = "86: Eighty Six" + +# The API key is stored in data/config.json; import it via the settings system. +from src.config.settings import settings # noqa: E402 + +TMDB_API_KEY: str = settings.tmdb_api_key or "299ae8f630a31bda814263c551361448" + +# --------------------------------------------------------------------------- +# Required XML tags that must exist and be non-empty after creation/repair +# --------------------------------------------------------------------------- +REQUIRED_SINGLE_TAGS = [ + "title", + "originaltitle", + "sorttitle", + "year", + "plot", + "outline", + "runtime", + "premiered", + "status", + "tmdbid", + "imdbid", + "tvdbid", + "dateadded", + "watched", + # mpaa may be "TV-MA" (US) or an FSK value depending on config + "mpaa", +] + +REQUIRED_MULTI_TAGS = [ + "genre", + "studio", + "country", +] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _parse_nfo(nfo_path: Path) -> etree._Element: + """Parse NFO file and return root element.""" + tree = etree.parse(str(nfo_path)) + return tree.getroot() + + +def _assert_required_tags(root: etree._Element, nfo_path: Path) -> None: + """Assert every required tag is present and non-empty.""" + missing = [] + 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_path}:\n " + + "\n ".join(missing) + + f"\n\nFull NFO:\n{etree.tostring(root, pretty_print=True).decode()}" + ) + + +def _assert_correct_ids(root: etree._Element) -> None: + """Assert that all three IDs have the expected values.""" + tmdbid = root.findtext(".//tmdbid") + imdbid = root.findtext(".//imdbid") + tvdbid = root.findtext(".//tvdbid") + + assert tmdbid == str(TMDB_ID), f"tmdbid: expected {TMDB_ID}, got {tmdbid!r}" + assert imdbid == IMDB_ID, f"imdbid: expected {IMDB_ID!r}, got {imdbid!r}" + assert tvdbid == str(TVDB_ID), f"tvdbid: expected {TVDB_ID}, got {tvdbid!r}" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@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 with the real API key.""" + return NFOService( + tmdb_api_key=TMDB_API_KEY, + anime_directory=str(anime_dir), + image_size="w500", + auto_create=True, + ) + + +# --------------------------------------------------------------------------- +# Test 1 – Create NFO and verify all required fields +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_create_nfo_has_all_required_fields( + nfo_service: NFOService, + anime_dir: Path, +) -> None: + """Create a real tvshow.nfo via TMDB and assert every required tag is present. + + Uses 86: Eighty Six (TMDB 100565) as the reference show. + All checks are performed against the TMDB API using the configured key. + """ + series_folder = SHOW_NAME + series_dir = anime_dir / series_folder + series_dir.mkdir() + + # Patch image downloads to avoid network hits for images + from unittest.mock import AsyncMock, patch + + with patch.object( + nfo_service.image_downloader, + "download_all_media", + new_callable=AsyncMock, + return_value={"poster": False, "logo": False, "fanart": False}, + ): + nfo_path = await nfo_service.create_tvshow_nfo( + serie_name=SHOW_NAME, + serie_folder=series_folder, + year=2021, + download_poster=False, + download_logo=False, + download_fanart=False, + ) + + assert nfo_path.exists(), "NFO file was not created" + + root = _parse_nfo(nfo_path) + + # --- Structural checks --- + _assert_required_tags(root, nfo_path) + + # --- Identity checks --- + _assert_correct_ids(root) + + # --- Spot-check concrete values --- + assert root.findtext(".//year") == "2021" + assert root.findtext(".//premiered") == "2021-04-11" + assert root.findtext(".//runtime") == "24" + assert root.findtext(".//status") == "Ended" + assert root.findtext(".//watched") == "false" + + # Plot must be non-trivial (at least 20 characters) + plot = root.findtext(".//plot") or "" + assert len(plot) >= 20, f"plot too short: {plot!r}" + + +# --------------------------------------------------------------------------- +# Test 2 – Strip NFO to ID-only, update, verify all fields restored +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_update_stripped_nfo_restores_all_fields( + nfo_service: NFOService, + anime_dir: Path, +) -> None: + """Write a minimal NFO with only the TMDB ID, run update_tvshow_nfo, and + verify that all required tags are present with correct values afterwards. + + This proves the repair pipeline works end-to-end with a real TMDB lookup. + """ + series_folder = SHOW_NAME + series_dir = anime_dir / series_folder + series_dir.mkdir() + + # Write the stripped NFO – only the tmdbid element, nothing else + stripped_xml = ( + '\n' + "\n" + f" {TMDB_ID}\n" + "\n" + ) + nfo_path = series_dir / "tvshow.nfo" + nfo_path.write_text(stripped_xml, encoding="utf-8") + + # Confirm the file is truly incomplete before the update + root_before = _parse_nfo(nfo_path) + assert root_before.findtext(".//title") is None, "Precondition failed: title exists in stripped NFO" + assert root_before.findtext(".//plot") is None, "Precondition failed: plot exists in stripped NFO" + + # Patch image downloads to avoid image network requests + from unittest.mock import AsyncMock, patch + + with patch.object( + nfo_service.image_downloader, + "download_all_media", + new_callable=AsyncMock, + return_value={"poster": False, "logo": False, "fanart": False}, + ): + updated_path = await nfo_service.update_tvshow_nfo( + serie_folder=series_folder, + download_media=False, + ) + + assert updated_path.exists(), "Updated NFO file not found" + + root_after = _parse_nfo(updated_path) + + # --- All required tags must now be present and non-empty --- + _assert_required_tags(root_after, updated_path) + + # --- IDs must match --- + _assert_correct_ids(root_after) + + # --- Concrete value checks --- + assert root_after.findtext(".//year") == "2021" + assert root_after.findtext(".//premiered") == "2021-04-11" + assert root_after.findtext(".//runtime") == "24" + assert root_after.findtext(".//status") == "Ended" + assert root_after.findtext(".//watched") == "false" + + # Plot must be non-trivial + plot = root_after.findtext(".//plot") or "" + assert len(plot) >= 20, f"plot too short after update: {plot!r}" + + # Original title must be the Japanese title + originaltitle = root_after.findtext(".//originaltitle") or "" + assert originaltitle, "originaltitle is empty after update" + # Should be the Japanese title (different from the English title) + title = root_after.findtext(".//title") or "" + assert originaltitle != "" and title != "", "title and originaltitle must both be set" + + # At least one genre + genres = [e.text for e in root_after.findall(".//genre") if e.text] + assert genres, "No genres found after update" + + # At least one studio + studios = [e.text for e in root_after.findall(".//studio") if e.text] + assert studios, "No studios found after update" + + # At least one actor with a name + actor_names = [e.text for e in root_after.findall(".//actor/name") if e.text] + assert actor_names, "No actors found after update"