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