Files
Aniworld/tests/api/test_anime_settings_endpoints.py
Lukas eabce18e41 feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints
Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.

Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
  NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
  repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
  update_series_nfo_status(); now uses update_nfo_status() and an
  explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
    GET  /settings            -> AnimeSettingsResponse
    PUT  /settings            -> AnimeSettingsResponse
                                 (body: name/folder/tmdb_id/tvdb_id/site,
                                  options: apply_to_nfo, rename_disk)
    POST /regenerate-nfo      -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
  AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it

Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
  no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
  validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
  'anime-settings' (label 'Anime Settings'), navigates to
  /anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'

Bug fix
- context-menu click handler was calling hide() BEFORE building the
  navigation URL, which cleared currentSeriesKey to null and produced
  /anime/settings?key=null. Captures the key into a local const first.
  Regression-locked by tests/frontend/unit/context_menu.test.js.

Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
  (GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
  regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
  legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)

Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix

Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
2026-06-21 07:52:22 +02:00

443 lines
15 KiB
Python

"""Pytest tests for the Anime Settings endpoints.
Covers:
- GET /api/anime/{key}/settings (happy path, 401, 404)
- PUT /api/anime/{key}/settings (validation, DB sync, NFO sync)
- POST /api/anime/{key}/regenerate-nfo (happy path, 404, 400 without tmdb_id)
Also regression-tests the bug-fix where _create_or_update_nfo previously
called a non-existent update_series_nfo_status method.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
# ============================================================================
# Test DB setup (in-memory SQLite)
# ============================================================================
@pytest_asyncio.fixture
async def test_db_session():
"""Override the DB dependency with an in-memory SQLite session."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
future=True,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def _override_db_session():
async with SessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
from src.server.utils.dependencies import (
get_database_session,
get_optional_database_session,
)
app.dependency_overrides[get_database_session] = _override_db_session
app.dependency_overrides[get_optional_database_session] = _override_db_session
# Seed the test DB
from sqlalchemy import update
from src.server.database.models import AnimeSeries as AS
from src.server.database.models import Base
from src.server.database.service import AnimeSeriesService
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as setup_session:
await AnimeSeriesService.create(
db=setup_session,
key="attack-on-titan",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan (2013)",
year=2013,
has_nfo=True,
nfo_path="/anime/Attack on Titan (2013)/tvshow.nfo",
)
await setup_session.execute(
update(AS).where(AS.key == "attack-on-titan").values(
tmdb_id=1429, tvdb_id=789
)
)
await setup_session.commit()
yield setup_session
app.dependency_overrides.pop(get_database_session, None)
app.dependency_overrides.pop(get_optional_database_session, None)
await engine.dispose()
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture(autouse=True)
def reset_auth():
auth_service._hash = None
auth_service._failed.clear()
yield
auth_service._hash = None
auth_service._failed.clear()
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def authenticated_client(client):
await client.post(
"/api/auth/setup",
json={"master_password": "TestPassword123!"},
)
resp = await client.post(
"/api/auth/login",
json={"password": "TestPassword123!"},
)
token = resp.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_anime_service():
service = MagicMock()
service.list_series_with_filters = AsyncMock(return_value=[
{
"key": "attack-on-titan",
"name": "Attack on Titan",
"site": "aniworld.to",
"folder": "Attack on Titan (2013)",
"tmdb_id": 1429,
},
])
service.update_nfo_status = AsyncMock()
service.update_series_nfo_status = AsyncMock()
service.rename_folder_if_needed = AsyncMock(return_value=True)
if not hasattr(service, "_app"):
service._app = MagicMock()
service._app.list.GetList.return_value = []
from src.server.utils import dependencies as deps
app.dependency_overrides[deps.get_anime_service] = lambda: service
yield service
app.dependency_overrides.pop(deps.get_anime_service, None)
# ============================================================================
# GET /api/anime/{key}/settings
# ============================================================================
class TestGetAnimeSettingsEndpoint:
@pytest.mark.asyncio
async def test_returns_200_with_full_payload(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.get(
"/api/anime/attack-on-titan/settings"
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["key"] == "attack-on-titan"
assert data["name"] == "Attack on Titan"
assert data["tmdb_id"] == 1429
assert data["tvdb_id"] == 789
assert data["has_nfo"] is True
assert "folder" in data
@pytest.mark.asyncio
async def test_returns_404_for_unknown_key(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.get(
"/api/anime/no-such-series/settings"
)
assert resp.status_code == 404
assert "not found" in resp.json()["detail"].lower()
@pytest.mark.asyncio
async def test_returns_401_without_auth(self, client):
resp = await client.get("/api/anime/attack-on-titan/settings")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_includes_episode_counts(
self, authenticated_client, mock_anime_service, test_db_session
):
from src.server.database.models import Episode
# Need a fresh engine to insert episodes (test_db_session is async)
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False, future=True,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
from src.server.database.models import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# The override yields a different session each call — we need to
# seed via the test_db_session and verify count from there.
# Simplest: just rely on the absence of any episodes in the seed
resp = await authenticated_client.get(
"/api/anime/attack-on-titan/settings"
)
assert resp.status_code == 200
data = resp.json()
# Default seed has zero episodes
assert data["episode_count"] == 0
assert data["missing_episode_count"] == 0
await engine.dispose()
# ============================================================================
# PUT /api/anime/{key}/settings
# ============================================================================
class TestUpdateAnimeSettingsEndpoint:
@pytest.mark.asyncio
async def test_updates_name_only(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.put(
"/api/anime/attack-on-titan/settings",
json={"name": "Attack on Titan: Final Season"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["name"] == "Attack on Titan: Final Season"
@pytest.mark.asyncio
async def test_updates_tmdb_id_and_regenerates_nfo(
self, authenticated_client, mock_anime_service, test_db_session
):
with patch(
"src.server.api.nfo._create_or_update_nfo",
AsyncMock(return_value=["title", "tmdbid"]),
) as mock_create:
resp = await authenticated_client.put(
"/api/anime/attack-on-titan/settings",
json={"tmdb_id": 9999, "apply_to_nfo": True},
)
assert resp.status_code == 200, resp.text
assert mock_create.await_count == 1
# NFO regeneration uses the (just-updated) DB value
assert mock_create.await_args.kwargs["tmdb_id"] == 9999
@pytest.mark.asyncio
async def test_updates_folder_and_renames_disk(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.put(
"/api/anime/attack-on-titan/settings",
json={"folder": "Attack on Titan (2013) HD", "rename_disk": True},
)
assert resp.status_code == 200, resp.text
assert mock_anime_service.rename_folder_if_needed.await_count == 1
@pytest.mark.asyncio
async def test_rejects_empty_name(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.put(
"/api/anime/attack-on-titan/settings",
json={"name": ""},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_rejects_unsafe_folder_path(
self, authenticated_client, mock_anime_service, test_db_session
):
# "///" sanitizes to empty -> ValueError -> 422
resp = await authenticated_client.put(
"/api/anime/attack-on-titan/settings",
json={"folder": "///"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_rejects_negative_tmdb_id(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.put(
"/api/anime/attack-on-titan/settings",
json={"tmdb_id": -5},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_rejects_tmdb_id_too_large(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.put(
"/api/anime/attack-on-titan/settings",
json={"tmdb_id": 99999999999},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_returns_404_for_unknown_key(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.put(
"/api/anime/no-such-series/settings",
json={"name": "X"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_returns_401_without_auth(self, client):
resp = await client.put(
"/api/anime/attack-on-titan/settings",
json={"name": "X"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_returns_400_when_apply_to_nfo_without_tmdb_id(
self, authenticated_client, mock_anime_service, test_db_session
):
from sqlalchemy import update
from src.server.database.models import AnimeSeries as AS
await test_db_session.execute(
update(AS).where(AS.key == "attack-on-titan").values(tmdb_id=None)
)
await test_db_session.commit()
resp = await authenticated_client.put(
"/api/anime/attack-on-titan/settings",
json={"apply_to_nfo": True},
)
assert resp.status_code == 400
assert "tmdb" in resp.json()["detail"].lower()
# ============================================================================
# POST /api/anime/{key}/regenerate-nfo
# ============================================================================
class TestRegenerateNfoEndpoint:
@pytest.mark.asyncio
async def test_returns_200_on_success(
self, authenticated_client, mock_anime_service, test_db_session
):
with patch(
"src.server.api.nfo._create_or_update_nfo",
AsyncMock(return_value=["title", "tmdbid"]),
) as mock_create:
resp = await authenticated_client.post(
"/api/anime/attack-on-titan/regenerate-nfo"
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["success"] is True
assert data["repaired_tags"] == ["title", "tmdbid"]
assert mock_create.await_count == 1
@pytest.mark.asyncio
async def test_returns_400_when_no_tmdb_id(
self, authenticated_client, mock_anime_service, test_db_session
):
from sqlalchemy import update
from src.server.database.models import AnimeSeries as AS
await test_db_session.execute(
update(AS).where(AS.key == "attack-on-titan").values(tmdb_id=None)
)
await test_db_session.commit()
resp = await authenticated_client.post(
"/api/anime/attack-on-titan/regenerate-nfo"
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_returns_404_for_unknown_key(
self, authenticated_client, mock_anime_service, test_db_session
):
resp = await authenticated_client.post(
"/api/anime/no-such/regenerate-nfo"
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_returns_401_without_auth(self, client):
resp = await client.post(
"/api/anime/attack-on-titan/regenerate-nfo"
)
assert resp.status_code == 401
# ============================================================================
# Renamed diagnostic endpoints — URL kept, function renamed
# ============================================================================
class TestRenamedDiagnosticEndpoints:
@pytest.mark.asyncio
async def test_get_diagnostics_still_works(
self, authenticated_client, mock_anime_service
):
resp = await authenticated_client.get(
"/api/nfo/attack-on-titan/diagnostics"
)
# 404 if no series, 200 if file exists, 503 if anime_dir unset
assert resp.status_code in (200, 404, 503)
# ============================================================================
# Bug regression test
# ============================================================================
class TestBugFixCreateOrUpdateNfo:
def test_update_nfo_status_method_exists(self):
"""AnimeService must expose update_nfo_status (the canonical name)."""
from src.server.services.anime_service import AnimeService
assert hasattr(AnimeService, "update_nfo_status"), (
"AnimeService.update_nfo_status must exist"
)
def test_nfo_api_calls_update_nfo_status(self):
"""api/nfo.py must call update_nfo_status (not the legacy name)."""
src = open(
"src/server/api/nfo.py"
).read()
assert "update_nfo_status(" in src, (
"api/nfo.py must call update_nfo_status on anime_service"
)
assert "update_series_nfo_status(" not in src, (
"api/nfo.py must NOT call the non-existent update_series_nfo_status"
)