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")
|
||||
Reference in New Issue
Block a user