test: add live TMDB integration tests for NFO creation and repair
This commit is contained in:
272
tests/integration/test_nfo_live_tmdb.py
Normal file
272
tests/integration/test_nfo_live_tmdb.py
Normal 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"
|
||||
Reference in New Issue
Block a user