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:
443
tests/api/test_anime_settings_endpoints.py
Normal file
443
tests/api/test_anime_settings_endpoints.py
Normal 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"
|
||||
)
|
||||
@@ -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")
|
||||
138
tests/frontend/e2e/anime_settings_page.spec.js
Normal file
138
tests/frontend/e2e/anime_settings_page.spec.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
501
tests/frontend/unit/anime_settings.test.js
Normal file
501
tests/frontend/unit/anime_settings.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
167
tests/frontend/unit/context_menu.test.js
Normal file
167
tests/frontend/unit/context_menu.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user