273 lines
9.0 KiB
Python
273 lines
9.0 KiB
Python
"""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"
|