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
This commit is contained in:
2026-06-21 07:52:22 +02:00
parent e050f6fa2d
commit eabce18e41
22 changed files with 3216 additions and 2302 deletions

View File

@@ -0,0 +1,443 @@
"""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"
)

View File

@@ -1,6 +1,17 @@
"""Tests for NFO API endpoints.
"""Tests for the NFO Management API endpoints.
This module tests all NFO management REST API endpoints.
Covers the live endpoints in src/server/api/nfo.py:
- GET /api/nfo/{key}/diagnostics
- POST /api/nfo/{key}/repair
- GET /api/nfo/{key}/validate
- GET /api/nfo/needs-repair
- POST /api/nfo/batch/repair
Note: Legacy endpoints referenced in v1.x (e.g. /api/nfo/{id}/check,
/create, /update, /content, /missing, /batch/create) no longer exist
in the codebase — they were replaced by the consolidated diagnostic,
repair, validate, needs-repair, batch/repair endpoints and the new
Anime Settings page (see tests/api/test_anime_settings_endpoints.py).
"""
from unittest.mock import AsyncMock, Mock, patch
@@ -8,24 +19,20 @@ import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.models.nfo import MediaFilesStatus, NFOCheckResponse, NFOCreateResponse
from src.server.services.auth_service import auth_service
@pytest.fixture(autouse=True)
def reset_auth():
"""Reset authentication state before each test."""
original_hash = auth_service._hash
auth_service._hash = None
auth_service._failed.clear()
yield
auth_service._hash = original_hash
auth_service._hash = None
auth_service._failed.clear()
@pytest.fixture
async def client():
"""Create an async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@@ -33,458 +40,67 @@ async def client():
@pytest.fixture
async def authenticated_client(client):
"""Create an authenticated test client with token."""
# Setup master password
await client.post(
"/api/auth/setup",
json={"master_password": "TestPassword123!"}
json={"master_password": "TestPassword123!"},
)
# Login to get token
response = await client.post(
resp = await client.post(
"/api/auth/login",
json={"password": "TestPassword123!"}
json={"password": "TestPassword123!"},
)
token = response.json()["access_token"]
# Add token to default headers
token = resp.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_series_app():
"""Create mock series app."""
app_mock = Mock()
serie = Mock()
serie.key = "test-anime"
serie.folder = "Test Anime (2024)"
serie.name = "Test Anime"
serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)")
# Mock the list manager
list_manager = Mock()
list_manager.GetList = Mock(return_value=[serie])
app_mock.list = list_manager
return app_mock
@pytest.fixture
def mock_nfo_service():
"""Create mock NFO service."""
service = Mock()
service.check_nfo_exists = AsyncMock(return_value=False)
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
return service
@pytest.fixture
def override_nfo_service_for_auth_tests():
"""Placeholder fixture for auth tests.
Auth tests accept both 401 and 503 status codes since NFO service
dependency checks for TMDB API key before auth is verified.
"""
yield
@pytest.fixture
def override_dependencies(mock_series_app, mock_nfo_service):
"""Override dependencies for authenticated NFO tests."""
from src.server.api.nfo import get_nfo_service
from src.server.utils.dependencies import get_series_app
app.dependency_overrides[get_series_app] = lambda: mock_series_app
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
yield
# Clean up only our overrides
if get_series_app in app.dependency_overrides:
del app.dependency_overrides[get_series_app]
if get_nfo_service in app.dependency_overrides:
del app.dependency_overrides[get_nfo_service]
class TestNFOCheckEndpoint:
"""Tests for GET /api/nfo/{serie_id}/check endpoint."""
class TestNFOAuthRequirements:
"""All NFO endpoints must require authentication."""
@pytest.mark.asyncio
async def test_check_nfo_requires_auth(
self,
override_nfo_service_for_auth_tests,
client
):
"""Test that check endpoint requires authentication.
Endpoint returns 503 if NFO service not configured (no TMDB API key),
or 401 if service is available but user not authenticated.
Both indicate endpoint is protected.
"""
response = await client.get("/api/nfo/test-anime/check")
assert response.status_code in (401, 503)
async def test_get_diagnostics_requires_auth(self, client):
resp = await client.get("/api/nfo/any-key/diagnostics")
assert resp.status_code in (401, 503)
@pytest.mark.asyncio
async def test_check_nfo_series_not_found(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
override_dependencies
):
"""Test check endpoint with non-existent series."""
mock_series_app.list.GetList = Mock(return_value=[])
response = await authenticated_client.get(
"/api/nfo/nonexistent/check"
async def test_post_repair_requires_auth(self, client):
resp = await client.post("/api/nfo/any-key/repair")
assert resp.status_code in (401, 503)
@pytest.mark.asyncio
async def test_get_validate_requires_auth(self, client):
resp = await client.get("/api/nfo/any-key/validate")
assert resp.status_code in (401, 503)
@pytest.mark.asyncio
async def test_get_needs_repair_requires_auth(self, client):
resp = await client.get("/api/nfo/needs-repair")
assert resp.status_code in (401, 503)
@pytest.mark.asyncio
async def test_post_batch_repair_requires_auth(self, client):
resp = await client.post(
"/api/nfo/batch/repair",
json=["key1", "key2"],
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_check_nfo_success(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test successful NFO check."""
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.get(
"/api/nfo/test-anime/check"
)
assert response.status_code == 200
data = response.json()
assert data["serie_id"] == "test-anime"
assert data["serie_folder"] == "Test Anime (2024)"
assert data["has_nfo"] is False
assert resp.status_code in (401, 503)
class TestNFOCreateEndpoint:
"""Tests for POST /api/nfo/{serie_id}/create endpoint."""
class TestNFOEndpointModels:
"""Verify the response models use the renamed classes (regression
test for the rename from NfoDiagnosticsResponse -> NfoSettingsResponse)."""
@pytest.mark.asyncio
async def test_create_nfo_requires_auth(
self,
client,
override_nfo_service_for_auth_tests
):
"""Test that create endpoint requires authentication."""
response = await client.post(
"/api/nfo/test-anime/create",
json={}
def test_renamed_settings_response_model_exists(self):
# Confirm the old names are gone
from src.server import models
from src.server.models.nfo import (
NfoRepairResponse,
NfoSeriesSettings,
NfoSettingsResponse,
)
assert response.status_code in (401, 503)
@pytest.mark.asyncio
async def test_create_nfo_success(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test successful NFO creation."""
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.post(
"/api/nfo/test-anime/create",
json={
"download_poster": True,
"download_logo": True,
"download_fanart": True
}
)
assert response.status_code == 200
data = response.json()
assert data["serie_id"] == "test-anime"
assert "NFO and media files created" in data["message"]
@pytest.mark.asyncio
async def test_create_nfo_already_exists(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test NFO creation when NFO already exists."""
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.post(
"/api/nfo/test-anime/create",
json={"overwrite_existing": False}
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_create_nfo_with_year(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test NFO creation with year parameter."""
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.post(
"/api/nfo/test-anime/create",
json={
"year": 2024,
"download_poster": True
}
)
assert response.status_code == 200
# Verify year was passed to service
mock_nfo_service.create_tvshow_nfo.assert_called_once()
call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args[1]
assert call_kwargs["year"] == 2024
class TestNFOUpdateEndpoint:
"""Tests for PUT /api/nfo/{serie_id}/update endpoint."""
@pytest.mark.asyncio
async def test_update_nfo_requires_auth(
self,
client,
override_nfo_service_for_auth_tests
):
"""Test that update endpoint requires authentication."""
response = await client.put("/api/nfo/test-anime/update")
assert response.status_code in (401, 503)
@pytest.mark.asyncio
async def test_update_nfo_not_found(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test update when NFO doesn't exist."""
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=False)
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.put(
"/api/nfo/test-anime/update"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_nfo_success(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test successful NFO update."""
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.put(
"/api/nfo/test-anime/update?download_media=true"
)
assert response.status_code == 200
data = response.json()
assert "NFO updated successfully" in data["message"]
class TestNFOContentEndpoint:
"""Tests for GET /api/nfo/{serie_id}/content endpoint."""
@pytest.mark.asyncio
async def test_get_content_requires_auth(
self,
client,
override_nfo_service_for_auth_tests
):
"""Test that content endpoint requires authentication."""
response = await client.get("/api/nfo/test-anime/content")
assert response.status_code in (401, 503)
@pytest.mark.asyncio
async def test_get_content_nfo_not_found(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test get content when NFO doesn't exist."""
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.get(
"/api/nfo/test-anime/content"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_content_success(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test successful content retrieval."""
# Create NFO file
anime_dir = tmp_path / "Test Anime (2024)"
anime_dir.mkdir()
nfo_file = anime_dir / "tvshow.nfo"
nfo_file.write_text("<tvshow><title>Test</title></tvshow>")
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.get(
"/api/nfo/test-anime/content"
)
assert response.status_code == 200
data = response.json()
assert "<tvshow>" in data["content"]
assert data["file_size"] > 0
class TestNFOMissingEndpoint:
"""Tests for GET /api/nfo/missing endpoint."""
@pytest.mark.asyncio
async def test_get_missing_requires_auth(
self,
client,
override_nfo_service_for_auth_tests
):
"""Test that missing endpoint requires authentication."""
response = await client.get("/api/nfo/missing")
assert response.status_code in (401, 503)
@pytest.mark.asyncio
async def test_get_missing_success(
self,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path,
override_dependencies
):
"""Test getting list of series without NFO."""
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.get("/api/nfo/missing")
assert response.status_code == 200
data = response.json()
assert "total_series" in data
assert "missing_nfo_count" in data
assert "series" in data
class TestNFOBatchCreateEndpoint:
"""Tests for POST /api/nfo/batch/create endpoint."""
@pytest.mark.asyncio
async def test_batch_create_requires_auth(
self,
client,
override_nfo_service_for_auth_tests
):
"""Test that batch create endpoint requires authentication."""
response = await client.post(
"/api/nfo/batch/create",
json={"serie_ids": ["test1", "test2"]}
)
assert response.status_code in (401, 503)
@pytest.mark.asyncio
async def test_batch_create_success(
self,
override_dependencies,
authenticated_client,
mock_series_app,
mock_nfo_service,
tmp_path
):
"""Test successful batch NFO creation."""
with patch('src.server.api.nfo.settings') as mock_settings:
mock_settings.anime_directory = str(tmp_path)
response = await authenticated_client.post(
"/api/nfo/batch/create",
json={
"serie_ids": ["test-anime"],
"download_media": True,
"skip_existing": False,
"max_concurrent": 3
}
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert "successful" in data
assert "results" in data
class TestNFOServiceDependency:
"""Tests for NFO service dependency."""
@pytest.mark.asyncio
async def test_nfo_service_unavailable_without_api_key(
self,
authenticated_client
):
"""Test NFO endpoints fail gracefully without TMDB API key.
This test verifies that when the NFO service dependency raises an
HTTPException 503 due to missing TMDB API key, the endpoint returns 503.
"""
from fastapi import HTTPException, status
from src.server.api.nfo import get_nfo_service
# Create a dependency that raises HTTPException 503 (simulating missing API key)
async def fail_nfo_service():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="NFO service not configured: TMDB API key not available"
)
# Override NFO service to simulate missing API key
app.dependency_overrides[get_nfo_service] = fail_nfo_service
try:
response = await authenticated_client.get(
"/api/nfo/test-anime/check"
)
assert response.status_code == 503
data = response.json()
assert "not configured" in data["detail"]
finally:
# Clean up override
if get_nfo_service in app.dependency_overrides:
del app.dependency_overrides[get_nfo_service]
nfo_module = models.nfo
assert hasattr(nfo_module, "NfoSettingsResponse")
assert hasattr(nfo_module, "NfoSeriesSettings")
assert hasattr(nfo_module, "NfoRepairResponse")
# The diagnostic prefix should NOT be present anymore
assert not hasattr(nfo_module, "NfoDiagnosticsResponse")
assert not hasattr(nfo_module, "NfoSeriesDiagnostics")

View File

@@ -0,0 +1,138 @@
/**
* Playwright E2E: Anime Settings page
*
* Verifies the new flow after the rename from "NFO Diagnostics" to
* "Anime Settings":
* 1. Worker-scoped auth: login via API ONCE per worker and reuse the
* JWT across tests (avoids the server's per-IP rate limit).
* 2. Navigate to /
* 3. Right-click on first .series-card
* 4. Click "Anime Settings" in the context menu
* 5. Verify navigation to /anime/settings?key=...
* 6. Verify the settings form is populated with the series data
*
* Run with: `E2E_PASSWORD=... npx playwright test anime_settings_page.spec.js`
*/
import { test as base, expect } from '@playwright/test';
const BASE_URL = process.env.E2E_BASE_URL || 'http://127.0.0.1:8000';
const TEST_PASSWORD = process.env.E2E_PASSWORD;
// Worker-scoped auth fixture: login once per worker, share the token
// across all tests to avoid triggering the server's login rate limit.
const test = base.extend({
authedPage: async ({ page, context }, use) => {
test.skip(!TEST_PASSWORD, 'Set E2E_PASSWORD env var to run this test');
const resp = await context.request.post(`${BASE_URL}/api/auth/login`, {
data: { password: TEST_PASSWORD },
});
// If the IP is locked out (429), skip the entire suite so the
// user can wait for the lockout to expire.
test.skip(
resp.status() === 429,
'Server login rate-limited (429). Wait ~5 minutes.',
);
expect(resp.status(), 'auth/login should succeed').toBe(200);
const body = await resp.json();
const token = body.access_token;
// Visit any page from this origin so we can write to localStorage
await page.goto(`${BASE_URL}/login`);
await page.evaluate((t) => {
localStorage.setItem('access_token', t);
}, token);
await use(page);
},
});
test.describe('Anime Settings page (right-click flow)', () => {
test('right-click series card opens Anime Settings page', async ({ authedPage: page }) => {
await page.goto(BASE_URL);
// Wait for at least one series card to render
await page.waitForSelector('.series-card', { timeout: 15000 });
// Right-click on the first series card
const firstCard = page.locator('.series-card').first();
const key = await firstCard.getAttribute('data-key');
expect(key, 'series card must have data-key').toBeTruthy();
await firstCard.click({ button: 'right' });
// The custom context menu should appear with the renamed action
const menuItem = page.locator('[data-action="anime-settings"]');
await expect(menuItem).toBeVisible({ timeout: 5000 });
// Click the menu item — should navigate to /anime/settings?key=...
await menuItem.click();
await page.waitForURL(/\/anime\/settings/, { timeout: 10000 });
// The settings page should show the editor section (not loading/error)
await expect(page.locator('#settings-section')).toBeVisible({
timeout: 10000,
});
// The form input for name should be populated (i.e. not empty)
const nameInput = page.locator('#field-name');
await expect(nameInput).toBeVisible();
const nameValue = await nameInput.inputValue();
expect(nameValue.length).toBeGreaterThan(0);
// The URL should carry the key param
const url = new URL(page.url());
expect(url.pathname).toBe('/anime/settings');
expect(url.searchParams.get('key')).toBe(key);
});
test('direct navigation to /anime/settings?key=... works', async ({ authedPage: page }) => {
await page.goto(BASE_URL);
await page.waitForSelector('.series-card', { timeout: 15000 });
const firstCard = page.locator('.series-card').first();
const key = await firstCard.getAttribute('data-key');
expect(key).toBeTruthy();
await page.goto(`${BASE_URL}/anime/settings?key=${encodeURIComponent(key)}`);
await expect(page.locator('#settings-section')).toBeVisible({
timeout: 10000,
});
// Overview should show the key
await expect(page.locator('#overview-key')).toContainText(key);
});
test('legacy /settings/nfo URL redirects to /anime/settings', async ({ authedPage: page }) => {
const resp = await page.goto(`${BASE_URL}/settings/nfo`, {
waitUntil: 'load',
});
// FastAPI RedirectResponse returns 301 (permanent) or 307 (temp)
expect([301, 307, 200]).toContain(resp.status());
// Following the redirect should land on /anime/settings
const finalPath = new URL(page.url()).pathname;
// Allow trailing slash variants
expect(['/anime/settings', '/anime/settings/']).toContain(finalPath);
});
test('context menu no longer shows NFO Diagnostics', async ({ authedPage: page }) => {
await page.goto(BASE_URL);
await page.waitForSelector('.series-card', { timeout: 15000 });
const firstCard = page.locator('.series-card').first();
await firstCard.click({ button: 'right' });
// The legacy action should be gone
const legacy = page.locator('[data-action="nfo-diagnostics"]');
await expect(legacy).toHaveCount(0);
});
test('context menu shows Anime Settings action', async ({ authedPage: page }) => {
await page.goto(BASE_URL);
await page.waitForSelector('.series-card', { timeout: 15000 });
const firstCard = page.locator('.series-card').first();
await firstCard.click({ button: 'right' });
const menuItem = page.locator('[data-action="anime-settings"]');
await expect(menuItem).toBeVisible({ timeout: 5000 });
// Verify label says "Anime Settings" (not "NFO Diagnostics")
await expect(menuItem).toContainText(/Anime Settings/);
});
});

View File

@@ -0,0 +1,501 @@
/**
* Unit tests for AniWorld.AnimeSettingsManager
*
* Tests every public function on the per-anime settings page JS module:
* - init() : binds DOM events, starts initial load
* - loadSeries(key) : fetches /api/anime/{key}/settings
* - saveSettings(opts) : PUTs /api/anime/{key}/settings
* - regenerateNfo() : POSTs /api/anime/{key}/regenerate-nfo
* - validateField(name, value) : client-side validation
* - populateForm(data) : fills form from payload
* - showSaveSuccess(msg) : success toast
* - showError(msg) : error toast
*
* Also verifies the auth header is included on every fetch.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Polyfill fetch globally (Vitest JSDOM env provides it but stub for clarity)
function mockFetchSequence(responses) {
let callIndex = 0;
global.fetch = vi.fn(async () => {
const r = responses[callIndex++];
if (!r) {
throw new Error('Unexpected fetch call');
}
return {
ok: r.ok !== false,
status: r.status || 200,
json: async () => r.body || {},
text: async () => r.text || JSON.stringify(r.body || {}),
};
});
}
function readModuleSource() {
// Load the AnimeSettingsManager source via fs and eval inside a
// window-like scope. This mirrors the production IIFE pattern.
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(
path.resolve(__dirname, '../../../src/server/web/static/js/pages/anime-settings.js'),
'utf8'
);
// Execute in global scope
// eslint-disable-next-line no-eval
(0, eval)(src);
return global.AniWorld.AnimeSettingsManager;
}
describe('AnimeSettingsManager', () => {
let manager;
beforeEach(() => {
// Build a minimal DOM tree covering every id the module touches
document.body.innerHTML = `
<div id="no-key-section" class="hidden"></div>
<div id="loading-section" class="hidden"></div>
<div id="error-section" class="hidden"></div>
<div id="settings-section" class="hidden"></div>
<select id="series-select"></select>
<button id="load-series-btn"></button>
<button id="retry-btn"></button>
<p id="error-message"></p>
<h2 id="series-display-name"></h2>
<span id="badge-loading-status"></span>
<span id="badge-has-nfo"></span>
<span id="badge-episode-counts"></span>
<code id="overview-key"></code>
<span id="overview-year"></span>
<span id="overview-loading-status"></span>
<span id="overview-episode-count"></span>
<span id="overview-missing-count"></span>
<span id="overview-nfo-created"></span>
<span id="overview-nfo-updated"></span>
<code id="overview-nfo-path"></code>
<input type="text" id="field-name" />
<input type="text" id="field-folder" />
<input type="number" id="field-tmdb-id" />
<input type="number" id="field-tvdb-id" />
<input type="text" id="field-site" />
<small id="hint-name"></small>
<small id="hint-folder"></small>
<small id="hint-tmdb-id"></small>
<small id="hint-tvdb-id"></small>
<small id="hint-site"></small>
<button id="save-db-btn"></button>
<button id="save-db-nfo-btn"></button>
<button id="reset-btn"></button>
<input type="checkbox" id="rename-disk-toggle" />
<button id="regenerate-nfo-btn"></button>
<button id="view-nfo-btn"></button>
<pre id="nfo-content" class="hidden"></pre>
`;
// Provide the shared helpers the module expects
global.AniWorld = {
Auth: {
getToken: vi.fn(() => 'fake-jwt-token'),
checkAuth: vi.fn().mockResolvedValue(true),
},
UiUtils: {
showToast: vi.fn(),
},
};
// Load module
manager = readModuleSource();
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
// -------------------------------------------------------------------
// init()
// -------------------------------------------------------------------
describe('init()', () => {
it('reads ?key= from URL and calls loadSeries', async () => {
mockFetchSequence([{
status: 200,
body: { key: 'aot', name: 'AOT', tmdb_id: 1 },
}]);
// Replace window.location with a controllable mock
delete window.location;
window.location = { search: '?key=aot', href: 'http://x/anime/settings?key=aot' };
manager.init();
await new Promise((r) => setTimeout(r, 10));
expect(global.fetch).toHaveBeenCalledTimes(1);
const url = global.fetch.mock.calls[0][0];
expect(url).toContain('/api/anime/aot/settings');
});
it('shows no-key section when no ?key is present', async () => {
delete window.location;
window.location = { search: '', href: 'http://x/anime/settings' };
// Stub populateSeriesSelect to avoid network
global.fetch = vi.fn(async () => ({
ok: true, status: 200,
json: async () => [],
text: async () => '[]',
}));
manager.init();
await new Promise((r) => setTimeout(r, 10));
const section = document.getElementById('no-key-section');
expect(section.classList.contains('hidden')).toBe(false);
});
});
// -------------------------------------------------------------------
// loadSeries()
// -------------------------------------------------------------------
describe('loadSeries()', () => {
it('calls fetch with auth header', async () => {
mockFetchSequence([{
status: 200,
body: {
key: 'naruto',
name: 'Naruto',
site: 'aniworld.to',
folder: 'Naruto (2002)',
year: 2002,
tmdb_id: 20,
tvdb_id: null,
has_nfo: true,
nfo_path: '/anime/Naruto/tvshow.nfo',
episode_count: 5,
missing_episode_count: 2,
loading_status: 'completed',
},
}]);
await manager.loadSeries('naruto');
const [url, opts] = global.fetch.mock.calls[0];
expect(url).toBe('/api/anime/naruto/settings');
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
});
it('populates the form on success', async () => {
mockFetchSequence([{
status: 200,
body: {
key: 'naruto',
name: 'Naruto',
site: 'aniworld.to',
folder: 'Naruto (2002)',
year: 2002,
tmdb_id: 20,
tvdb_id: null,
has_nfo: true,
nfo_path: '/anime/Naruto/tvshow.nfo',
episode_count: 5,
missing_episode_count: 2,
loading_status: 'completed',
},
}]);
await manager.loadSeries('naruto');
expect(document.getElementById('field-name').value).toBe('Naruto');
expect(document.getElementById('field-folder').value).toBe('Naruto (2002)');
expect(document.getElementById('field-tmdb-id').value).toBe('20');
});
it('handles 404 by showing the error section', async () => {
mockFetchSequence([{ status: 404, body: { detail: 'not found' } }]);
await manager.loadSeries('missing');
expect(
document.getElementById('error-section').classList.contains('hidden')
).toBe(false);
});
it('handles 401 by calling showError', async () => {
mockFetchSequence([{ status: 401, body: { detail: 'unauthorized' } }]);
await manager.loadSeries('whatever');
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect.stringContaining('authenticated'),
'error'
);
});
});
// -------------------------------------------------------------------
// saveSettings()
// -------------------------------------------------------------------
describe('saveSettings()', () => {
beforeEach(async () => {
// First, set currentKey via loadSeries (matches URL-based init)
delete window.location;
window.location = { search: '?key=a', href: 'http://x/?key=a' };
mockFetchSequence([{
status: 200,
body: {
key: 'a', name: 'A', folder: 'A', site: 's',
tmdb_id: null, tvdb_id: null, has_nfo: false,
nfo_path: null, episode_count: 0, missing_episode_count: 0,
loading_status: 'completed',
},
}]);
await manager.loadSeries('a');
// Now overwrite the form values with what we want to save.
// (loadSeries populates form from server, but we want to test
// that saveSettings sends the user-typed values, so we mutate
// them AFTER the load.)
document.getElementById('field-name').value = 'New Name';
document.getElementById('field-folder').value = 'New Folder';
document.getElementById('field-tmdb-id').value = '1234';
document.getElementById('field-tvdb-id').value = '';
document.getElementById('field-site').value = 'https://x';
});
it('sends PUT with auth header and JSON body', async () => {
mockFetchSequence([{
status: 200,
body: {
key: 'a',
name: 'New Name',
folder: 'New Folder',
tmdb_id: 1234,
},
}]);
await manager.saveSettings({ applyToNfo: false });
const [url, opts] = global.fetch.mock.calls[0];
expect(url).toBe('/api/anime/a/settings');
expect(opts.method).toBe('PUT');
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
expect(opts.headers['Content-Type']).toBe('application/json');
const body = JSON.parse(opts.body);
expect(body.name).toBe('New Name');
expect(body.folder).toBe('New Folder');
// form inputs return strings; the module passes them through
// verbatim — the server coerces to int.
expect(String(body.tmdb_id)).toBe('1234');
expect(body.apply_to_nfo).toBe(false);
});
it('shows success toast on save', async () => {
mockFetchSequence([{
status: 200,
body: { key: 'a', name: 'New Name' },
}]);
await manager.saveSettings({ applyToNfo: false });
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect.stringContaining('saved'),
'success'
);
});
it('shows "regenerated" message when applyToNfo=true', async () => {
mockFetchSequence([{
status: 200,
body: { key: 'a', name: 'New Name', has_nfo: true },
}]);
await manager.saveSettings({ applyToNfo: true });
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect.stringContaining('regenerated'),
'success'
);
});
it('shows error toast on 422', async () => {
mockFetchSequence([{ status: 422, body: { detail: 'bad tmdb_id' } }]);
await manager.saveSettings({ applyToNfo: false });
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect.stringContaining('Validation'),
'error'
);
});
});
// -------------------------------------------------------------------
// regenerateNfo()
// -------------------------------------------------------------------
describe('regenerateNfo()', () => {
beforeEach(async () => {
delete window.location;
window.location = { search: '?key=a', href: 'http://x/?key=a' };
mockFetchSequence([{
status: 200,
body: {
key: 'a', name: 'A', folder: 'A', site: 's',
tmdb_id: null, tvdb_id: null, has_nfo: false,
nfo_path: null, episode_count: 0, missing_episode_count: 0,
loading_status: 'completed',
},
}]);
await manager.loadSeries('a');
});
it('calls POST /regenerate-nfo and shows success toast', async () => {
mockFetchSequence([{
status: 200,
body: {
success: true,
message: 'NFO regenerated.',
repaired_tags: ['title'],
},
}]);
await manager.regenerateNfo();
const [url, opts] = global.fetch.mock.calls[0];
expect(url).toBe('/api/anime/a/regenerate-nfo');
expect(opts.method).toBe('POST');
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
'NFO regenerated.',
'success'
);
});
it('shows error toast on 400 (no tmdb_id)', async () => {
mockFetchSequence([{ status: 400, body: { detail: 'no TMDB ID' } }]);
await manager.regenerateNfo();
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
expect.stringContaining('Cannot regenerate'),
'error'
);
});
});
// -------------------------------------------------------------------
// validateField()
// -------------------------------------------------------------------
describe('validateField()', () => {
it('rejects empty name', () => {
expect(manager.validateField('name', '')).toMatch(/empty/i);
expect(manager.validateField('name', null)).toMatch(/empty/i);
});
it('rejects too-long name', () => {
expect(manager.validateField('name', 'x'.repeat(501))).toMatch(/exceeds/);
});
it('accepts valid name', () => {
expect(manager.validateField('name', 'Naruto')).toBeNull();
});
it('rejects folder with path traversal', () => {
expect(manager.validateField('folder', '../etc')).toMatch(/path traversal/i);
});
it('rejects folder with invalid characters', () => {
expect(manager.validateField('folder', 'foo\x00bar')).toMatch(/invalid/i);
});
it('accepts tmdb_id as integer string', () => {
expect(manager.validateField('tmdb_id', '12345')).toBeNull();
});
it('rejects tmdb_id = "abc"', () => {
expect(manager.validateField('tmdb_id', 'abc')).toMatch(/integer/i);
});
it('rejects negative tmdb_id', () => {
expect(manager.validateField('tmdb_id', '-5')).toMatch(/positive/i);
});
it('rejects oversized tmdb_id', () => {
expect(manager.validateField('tmdb_id', '99999999999')).toMatch(/10 digits/i);
});
it('accepts empty tvdb_id (optional)', () => {
expect(manager.validateField('tvdb_id', '')).toBeNull();
expect(manager.validateField('tvdb_id', undefined)).toBeNull();
});
it('rejects negative tvdb_id', () => {
expect(manager.validateField('tvdb_id', '-1')).toMatch(/positive/i);
});
it('accepts valid site', () => {
expect(manager.validateField('site', 'https://aniworld.to')).toBeNull();
});
it('rejects too-long site', () => {
expect(manager.validateField('site', 'x'.repeat(501))).toMatch(/exceeds/);
});
it('returns null for unknown field name', () => {
expect(manager.validateField('mystery_field', 'anything')).toBeNull();
});
});
// -------------------------------------------------------------------
// populateForm()
// -------------------------------------------------------------------
describe('populateForm()', () => {
it('sets all overview and form fields', () => {
manager.populateForm({
key: 'a',
name: 'A',
site: 'aniworld.to',
folder: 'A (2020)',
year: 2020,
tmdb_id: 100,
tvdb_id: 200,
has_nfo: true,
nfo_path: '/anime/A/tvshow.nfo',
episode_count: 12,
missing_episode_count: 3,
loading_status: 'completed',
});
expect(document.getElementById('field-name').value).toBe('A');
expect(document.getElementById('field-folder').value).toBe('A (2020)');
expect(document.getElementById('field-tmdb-id').value).toBe('100');
expect(document.getElementById('field-tvdb-id').value).toBe('200');
expect(document.getElementById('overview-key').textContent).toBe('a');
expect(document.getElementById('overview-year').textContent).toBe('2020');
});
it('handles missing optional fields gracefully', () => {
manager.populateForm({ key: 'a', name: 'A' });
expect(document.getElementById('field-tmdb-id').value).toBe('');
expect(document.getElementById('field-tvdb-id').value).toBe('');
expect(document.getElementById('field-name').value).toBe('A');
});
});
// -------------------------------------------------------------------
// showSaveSuccess() / showError()
// -------------------------------------------------------------------
describe('showSaveSuccess()', () => {
it('calls AniWorld.UiUtils.showToast with success type', () => {
manager.showSaveSuccess('Saved!');
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
'Saved!', 'success'
);
});
});
describe('showError()', () => {
it('calls AniWorld.UiUtils.showToast with error type', () => {
manager.showError('Boom');
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
'Boom', 'error'
);
});
});
// -------------------------------------------------------------------
// Public API surface
// -------------------------------------------------------------------
it('exposes all expected public methods', () => {
expect(typeof manager.init).toBe('function');
expect(typeof manager.loadSeries).toBe('function');
expect(typeof manager.saveSettings).toBe('function');
expect(typeof manager.regenerateNfo).toBe('function');
expect(typeof manager.validateField).toBe('function');
expect(typeof manager.populateForm).toBe('function');
expect(typeof manager.showSaveSuccess).toBe('function');
expect(typeof manager.showError).toBe('function');
});
});

View File

@@ -0,0 +1,167 @@
/**
* Unit tests for AniWorld.ContextMenu
*
* Covers the right-click → "Anime Settings" navigation flow including
* the regression where `hide()` was called BEFORE the navigation
* `window.location.href` was built, which caused the key to be reset
* to null and the URL to become `/anime/settings?key=null`.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const SRC_PATH = resolve(
__dirname,
'../../../src/server/web/static/js/index/context-menu.js',
);
function loadContextMenu() {
// Reset module state so each test gets a fresh closure.
delete global.AniWorld;
const src = readFileSync(SRC_PATH, 'utf8');
// Indirect eval → runs in global scope so `var AniWorld = window.AniWorld
// || {}` mutates the real `global.AniWorld` (and through it,
// `window.AniWorld` since happy-dom exposes global on window).
// eslint-disable-next-line no-eval
(0, eval)(src);
return global.AniWorld.ContextMenu;
}
describe('ContextMenu — right-click → Anime Settings flow', () => {
beforeEach(() => {
document.body.innerHTML = '';
delete window.AniWorld;
delete global.AniWorld;
delete window.location;
window.location = { href: '' };
});
afterEach(() => {
vi.restoreAllMocks();
});
it('navigates to /anime/settings?key=<series-key> after menu click', () => {
const ctx = loadContextMenu();
expect(ctx).toBeTruthy();
expect(typeof ctx.show).toBe('function');
const grid = document.createElement('div');
grid.id = 'series-grid';
const card = document.createElement('div');
card.className = 'series-card';
card.setAttribute('data-key', 'attack-on-titan');
grid.appendChild(card);
document.body.appendChild(grid);
ctx.init();
card.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: 10,
clientY: 10,
}),
);
const menuItem = document.querySelector(
'[data-action="anime-settings"]',
);
expect(menuItem).toBeTruthy();
menuItem.click();
expect(window.location.href).toBe(
'/anime/settings?key=attack-on-titan',
);
});
it('encodes special characters in the key (URL-unsafe slugs)', () => {
const ctx = loadContextMenu();
const grid = document.createElement('div');
grid.id = 'series-grid';
const card = document.createElement('div');
card.className = 'series-card';
card.setAttribute('data-key', 'a/b c');
grid.appendChild(card);
document.body.appendChild(grid);
ctx.init();
card.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: 5,
clientY: 5,
}),
);
document.querySelector('[data-action="anime-settings"]').click();
expect(window.location.href).toBe('/anime/settings?key=a%2Fb%20c');
});
it('source captures the key before calling hide() — regression guard', () => {
// Static invariant: the click handler must read currentSeriesKey
// BEFORE calling hide(). This guards against regressions of the
// bug where hide() cleared currentSeriesKey before the URL was
// built, resulting in /anime/settings?key=null.
const src = readFileSync(SRC_PATH, 'utf8');
const clickHandlerMatch = src.match(
/querySelector\('\[data-action="anime-settings"\]'\)\.addEventListener\('click',\s*function\s*\(\)\s*{([\s\S]*?)\}\);/,
);
expect(clickHandlerMatch, 'click handler should exist').toBeTruthy();
const body = clickHandlerMatch[1];
expect(body).toMatch(/currentSeriesKey/);
expect(body).toMatch(/\bhide\s*\(\s*\)/);
expect(body).toMatch(/const\s+key\s*=\s*currentSeriesKey/);
});
it('does not expose legacy nfo-diagnostics action', () => {
const ctx = loadContextMenu();
const grid = document.createElement('div');
grid.id = 'series-grid';
const card = document.createElement('div');
card.className = 'series-card';
card.setAttribute('data-key', 'k');
grid.appendChild(card);
document.body.appendChild(grid);
ctx.init();
card.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: 5,
clientY: 5,
}),
);
expect(
document.querySelector('[data-action="nfo-diagnostics"]'),
).toBeNull();
expect(
document.querySelector('[data-action="anime-settings"]'),
).toBeTruthy();
});
it('right-click outside a series card does not show the menu', () => {
const ctx = loadContextMenu();
const grid = document.createElement('div');
grid.id = 'series-grid';
document.body.appendChild(grid);
ctx.init();
// Click on empty grid area — should NOT show menu (no .series-card ancestor).
grid.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: 5,
clientY: 5,
}),
);
expect(document.querySelector('.context-menu')).toBeNull();
});
});