- test_add_anime_nfo_content.py: verify required NFO tags after anime add - test_sacrificial_princess_nfo.py: test full NFO generation and repair path
430 lines
15 KiB
Python
430 lines
15 KiB
Python
"""Integration test: add 'Sacrificial Princess And The King Of Beasts' and verify NFO completeness.
|
|
|
|
Simulates the production scenario where this anime is added and validates
|
|
that the generated tvshow.nfo contains plot, outline, and all other required
|
|
information. Also tests the repair path for an incomplete NFO.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from lxml import etree
|
|
|
|
from src.core.services.nfo_repair_service import (
|
|
NfoRepairService,
|
|
_read_tmdb_id,
|
|
find_missing_tags,
|
|
nfo_needs_repair,
|
|
)
|
|
from src.core.services.nfo_service import NFOService
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TMDB mock data matching production responses for this anime
|
|
# ---------------------------------------------------------------------------
|
|
SERIES_KEY = "sacrificial-princess-and-the-king-of-beasts"
|
|
SERIES_NAME = "Sacrificial Princess And The King Of Beasts"
|
|
SERIES_FOLDER = "Sacrificial Princess And The King Of Beasts (2023)"
|
|
TMDB_ID = 222093
|
|
|
|
MOCK_TMDB_DETAILS = {
|
|
"id": TMDB_ID,
|
|
"name": "Sacrificial Princess and the King of Beasts",
|
|
"original_name": "贄姫と獣の王",
|
|
"overview": (
|
|
"On the outskirts of the Demon King's realm lies a small village of "
|
|
"humans who offer a sacrifice to the beast king every year. Sariphi, "
|
|
"the latest sacrificial girl, expects to be devoured — but instead "
|
|
"her fearless nature catches the king's attention and she becomes "
|
|
"his unlikely companion."
|
|
),
|
|
"tagline": "A tale of love between a sacrifice and a beast king.",
|
|
"first_air_date": "2023-04-20",
|
|
"last_air_date": "2023-09-28",
|
|
"vote_average": 7.5,
|
|
"vote_count": 150,
|
|
"status": "Ended",
|
|
"episode_run_time": [24],
|
|
"number_of_seasons": 1,
|
|
"number_of_episodes": 24,
|
|
"genres": [
|
|
{"id": 16, "name": "Animation"},
|
|
{"id": 10749, "name": "Romance"},
|
|
{"id": 10765, "name": "Sci-Fi & Fantasy"},
|
|
],
|
|
"networks": [{"id": 160, "name": "TBS"}],
|
|
"production_companies": [{"id": 291, "name": "J.C.Staff"}],
|
|
"origin_country": ["JP"],
|
|
"poster_path": "/sacrificial_poster.jpg",
|
|
"backdrop_path": "/sacrificial_backdrop.jpg",
|
|
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
|
"credits": {
|
|
"cast": [
|
|
{
|
|
"id": 2072089,
|
|
"name": "Kana Hanazawa",
|
|
"character": "Sariphi",
|
|
"profile_path": "/hanazawa.jpg",
|
|
"order": 0,
|
|
},
|
|
{
|
|
"id": 1254783,
|
|
"name": "Satoshi Hino",
|
|
"character": "Leonhart",
|
|
"profile_path": "/hino.jpg",
|
|
"order": 1,
|
|
},
|
|
]
|
|
},
|
|
"images": {"logos": [{"file_path": "/sacrificial_logo.png"}]},
|
|
"seasons": [
|
|
{"season_number": 0, "name": "Specials"},
|
|
{"season_number": 1, "name": "Season 1"},
|
|
],
|
|
}
|
|
|
|
MOCK_CONTENT_RATINGS = {
|
|
"results": [
|
|
{"iso_3166_1": "DE", "rating": "12"},
|
|
{"iso_3166_1": "US", "rating": "TV-14"},
|
|
]
|
|
}
|
|
|
|
MOCK_SEARCH_RESULTS = {
|
|
"results": [
|
|
{
|
|
"id": TMDB_ID,
|
|
"name": "Sacrificial Princess and the King of Beasts",
|
|
"first_air_date": "2023-04-20",
|
|
"overview": (
|
|
"On the outskirts of the Demon King's realm lies a small village "
|
|
"of humans who offer a sacrifice to the beast king every year."
|
|
),
|
|
}
|
|
]
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tags that MUST be present and non-empty in a complete NFO
|
|
# ---------------------------------------------------------------------------
|
|
REQUIRED_TAGS = [
|
|
"title",
|
|
"originaltitle",
|
|
"year",
|
|
"plot",
|
|
"outline",
|
|
"runtime",
|
|
"premiered",
|
|
"status",
|
|
"tmdbid",
|
|
"imdbid",
|
|
"genre",
|
|
"studio",
|
|
"country",
|
|
"watched",
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def anime_dir(tmp_path: Path) -> Path:
|
|
"""Temporary anime directory."""
|
|
d = tmp_path / "anime"
|
|
d.mkdir()
|
|
return d
|
|
|
|
|
|
@pytest.fixture
|
|
def nfo_service(anime_dir: Path) -> NFOService:
|
|
"""NFOService configured for the temp directory."""
|
|
return NFOService(
|
|
tmdb_api_key="test_api_key",
|
|
anime_directory=str(anime_dir),
|
|
image_size="w500",
|
|
auto_create=True,
|
|
)
|
|
|
|
|
|
def _mock_tmdb_calls(nfo_service: NFOService):
|
|
"""Context manager that patches all TMDB calls with mock data."""
|
|
return _PatchContext(nfo_service)
|
|
|
|
|
|
class _PatchContext:
|
|
"""Helper to patch TMDB calls on an NFOService instance."""
|
|
|
|
def __init__(self, svc: NFOService):
|
|
self._svc = svc
|
|
self._patches = []
|
|
|
|
def __enter__(self):
|
|
p1 = patch.object(
|
|
self._svc.tmdb_client, "search_tv_show", new_callable=AsyncMock
|
|
)
|
|
p2 = patch.object(
|
|
self._svc.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
|
)
|
|
p3 = patch.object(
|
|
self._svc.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
|
)
|
|
p4 = patch.object(
|
|
self._svc.image_downloader, "download_all_media", new_callable=AsyncMock
|
|
)
|
|
p5 = patch.object(
|
|
self._svc.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
|
)
|
|
p6 = patch.object(
|
|
self._svc.tmdb_client, "close", new_callable=AsyncMock
|
|
)
|
|
|
|
self._patches = [p1, p2, p3, p4, p5, p6]
|
|
mocks = [p.start() for p in self._patches]
|
|
|
|
mocks[0].return_value = MOCK_SEARCH_RESULTS
|
|
mocks[1].return_value = MOCK_TMDB_DETAILS
|
|
mocks[2].return_value = MOCK_CONTENT_RATINGS
|
|
mocks[3].return_value = {"poster": True, "logo": True, "fanart": True}
|
|
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
for p in self._patches:
|
|
p.stop()
|
|
|
|
|
|
class TestSacrificialPrincessNFO:
|
|
"""Tests for 'Sacrificial Princess And The King Of Beasts' NFO generation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_anime_creates_complete_nfo(
|
|
self, nfo_service: NFOService, anime_dir: Path
|
|
) -> None:
|
|
"""Adding the anime produces an NFO with all required tags filled."""
|
|
series_path = anime_dir / SERIES_FOLDER
|
|
series_path.mkdir()
|
|
|
|
with _PatchContext(nfo_service):
|
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
serie_name=SERIES_NAME,
|
|
serie_folder=SERIES_FOLDER,
|
|
year=2023,
|
|
download_poster=True,
|
|
download_logo=True,
|
|
download_fanart=True,
|
|
)
|
|
|
|
assert nfo_path.exists(), f"NFO not created at {nfo_path}"
|
|
|
|
root = etree.parse(str(nfo_path)).getroot()
|
|
missing = []
|
|
for tag in REQUIRED_TAGS:
|
|
elems = root.findall(f".//{tag}")
|
|
if not elems or not any((e.text or "").strip() for e in elems):
|
|
missing.append(tag)
|
|
|
|
# Actor check
|
|
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 tags in NFO for '{SERIES_NAME}':\n"
|
|
f" {', '.join(missing)}\n\n"
|
|
f"NFO content:\n{nfo_path.read_text(encoding='utf-8')}"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nfo_plot_and_outline_are_meaningful(
|
|
self, nfo_service: NFOService, anime_dir: Path
|
|
) -> None:
|
|
"""Plot and outline must contain substantial descriptive text."""
|
|
series_path = anime_dir / SERIES_FOLDER
|
|
series_path.mkdir()
|
|
|
|
with _PatchContext(nfo_service):
|
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
serie_name=SERIES_NAME,
|
|
serie_folder=SERIES_FOLDER,
|
|
year=2023,
|
|
download_poster=False,
|
|
download_logo=False,
|
|
download_fanart=False,
|
|
)
|
|
|
|
root = etree.parse(str(nfo_path)).getroot()
|
|
|
|
plot = (root.findtext(".//plot") or "").strip()
|
|
outline = (root.findtext(".//outline") or "").strip()
|
|
|
|
assert len(plot) >= 20, f"Plot too short ({len(plot)} chars): {plot!r}"
|
|
assert len(outline) >= 20, f"Outline too short ({len(outline)} chars): {outline!r}"
|
|
|
|
# Should mention relevant keywords from the series
|
|
combined = (plot + outline).lower()
|
|
assert any(
|
|
kw in combined for kw in ("sacrifice", "beast", "king", "sariphi")
|
|
), f"Plot/outline missing expected content:\n plot={plot!r}\n outline={outline!r}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nfo_specific_values(
|
|
self, nfo_service: NFOService, anime_dir: Path
|
|
) -> None:
|
|
"""Verify specific metadata values match the anime."""
|
|
series_path = anime_dir / SERIES_FOLDER
|
|
series_path.mkdir()
|
|
|
|
with _PatchContext(nfo_service):
|
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
serie_name=SERIES_NAME,
|
|
serie_folder=SERIES_FOLDER,
|
|
year=2023,
|
|
download_poster=False,
|
|
download_logo=False,
|
|
download_fanart=False,
|
|
)
|
|
|
|
root = etree.parse(str(nfo_path)).getroot()
|
|
|
|
assert root.findtext(".//year") == "2023"
|
|
assert root.findtext(".//status") == "Ended"
|
|
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
|
assert root.findtext(".//imdbid") == "tt19896734"
|
|
assert root.findtext(".//watched") == "false"
|
|
assert root.findtext(".//premiered") == "2023-04-20"
|
|
|
|
genres = [g.text for g in root.findall(".//genre") if g.text]
|
|
assert "Animation" in genres
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_incomplete_nfo_detected_as_needing_repair(
|
|
self, anime_dir: Path
|
|
) -> None:
|
|
"""An NFO with only a <title> tag is detected as incomplete."""
|
|
series_path = anime_dir / SERIES_FOLDER
|
|
series_path.mkdir()
|
|
nfo_path = series_path / "tvshow.nfo"
|
|
|
|
# Simulate production state: minimal NFO with only title
|
|
nfo_path.write_text(
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
"<tvshow>\n"
|
|
f" <title>{SERIES_NAME}</title>\n"
|
|
"</tvshow>\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
assert nfo_needs_repair(nfo_path) is True
|
|
|
|
missing = find_missing_tags(nfo_path)
|
|
# All these should be detected as missing
|
|
for tag_label in ["plot", "year", "runtime", "premiered", "genre", "studio"]:
|
|
assert tag_label in missing, f"'{tag_label}' not detected as missing"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repair_fixes_incomplete_nfo(
|
|
self, nfo_service: NFOService, anime_dir: Path
|
|
) -> None:
|
|
"""NfoRepairService re-fetches and creates a complete NFO from an incomplete one."""
|
|
series_path = anime_dir / SERIES_FOLDER
|
|
series_path.mkdir()
|
|
nfo_path = series_path / "tvshow.nfo"
|
|
|
|
# Write an incomplete NFO with a tmdbid so update_tvshow_nfo can work
|
|
nfo_path.write_text(
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
"<tvshow>\n"
|
|
f" <title>{SERIES_NAME}</title>\n"
|
|
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
|
|
"</tvshow>\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
assert nfo_needs_repair(nfo_path) is True
|
|
|
|
# Patch TMDB calls for the update path
|
|
with patch.object(
|
|
nfo_service.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
|
), patch.object(
|
|
nfo_service.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
|
) as mock_details, patch.object(
|
|
nfo_service.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
|
) as mock_ratings, patch.object(
|
|
nfo_service.tmdb_client, "close", new_callable=AsyncMock
|
|
):
|
|
mock_details.return_value = MOCK_TMDB_DETAILS
|
|
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
|
|
|
repair_service = NfoRepairService(nfo_service)
|
|
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
|
|
|
assert repaired is True
|
|
|
|
# After repair, NFO should be complete
|
|
assert nfo_needs_repair(nfo_path) is False
|
|
|
|
# Verify content
|
|
root = etree.parse(str(nfo_path)).getroot()
|
|
plot = (root.findtext(".//plot") or "").strip()
|
|
assert len(plot) >= 20, f"Plot still incomplete after repair: {plot!r}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repair_recreates_nfo_without_tmdb_id(
|
|
self, nfo_service: NFOService, anime_dir: Path
|
|
) -> None:
|
|
"""If the NFO has no <tmdbid>, repair falls back to create_tvshow_nfo."""
|
|
series_path = anime_dir / SERIES_FOLDER
|
|
series_path.mkdir()
|
|
nfo_path = series_path / "tvshow.nfo"
|
|
|
|
# Simulate the production worst-case: only a title, no TMDB ID
|
|
nfo_path.write_text(
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
"<tvshow>\n"
|
|
f" <title>{SERIES_NAME}</title>\n"
|
|
"</tvshow>\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
assert _read_tmdb_id(nfo_path) is None
|
|
assert nfo_needs_repair(nfo_path) is True
|
|
|
|
with _PatchContext(nfo_service):
|
|
repair_service = NfoRepairService(nfo_service)
|
|
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
|
|
|
assert repaired is True
|
|
assert nfo_path.exists()
|
|
assert nfo_needs_repair(nfo_path) is False
|
|
|
|
root = etree.parse(str(nfo_path)).getroot()
|
|
plot = (root.findtext(".//plot") or "").strip()
|
|
assert len(plot) >= 20, f"Plot incomplete after recreate: {plot!r}"
|
|
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_nfo_not_repaired(
|
|
self, nfo_service: NFOService, anime_dir: Path
|
|
) -> None:
|
|
"""A complete NFO should not trigger a repair."""
|
|
series_path = anime_dir / SERIES_FOLDER
|
|
series_path.mkdir()
|
|
|
|
# First create a complete NFO
|
|
with _PatchContext(nfo_service):
|
|
await nfo_service.create_tvshow_nfo(
|
|
serie_name=SERIES_NAME,
|
|
serie_folder=SERIES_FOLDER,
|
|
year=2023,
|
|
download_poster=False,
|
|
download_logo=False,
|
|
download_fanart=False,
|
|
)
|
|
|
|
nfo_path = series_path / "tvshow.nfo"
|
|
assert nfo_path.exists()
|
|
assert nfo_needs_repair(nfo_path) is False
|
|
|
|
# Repair should be skipped
|
|
repair_service = NfoRepairService(nfo_service)
|
|
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
|
assert repaired is False
|