test: add live TMDB integration tests for NFO creation and repair

This commit is contained in:
2026-02-22 17:01:31 +01:00
parent c186e0d4f7
commit ddcac5a96d

View File

@@ -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 = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
"<tvshow>\n"
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
"</tvshow>\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"