feat(nfo): implement NFO diagnostics and repair

- Add NFO API endpoints: diagnostics, repair, validate, needs-repair
- Create /settings/nfo page with full NFO management UI
- Add NFO status section to edit modal with repair functionality
- Add anime details API for edit modal pre-fill data
- Fix auth test fixtures in test_nfo_diagnostics_repair.py

Implements NFO diagnostics when editing anime series via right-click menu.
Users can now check NFO status, see missing tags, and repair NFOs
directly from the edit modal or the dedicated NFO settings page.
This commit is contained in:
2026-06-14 19:34:58 +02:00
parent 75084b3941
commit 6dc3cda810
11 changed files with 2278 additions and 319 deletions

View File

@@ -1,5 +1,10 @@
"""Tests for NFO diagnostics and repair API endpoints."""
from unittest.mock import AsyncMock, Mock, patch
"""Tests for NFO diagnostics and repair API endpoints.
These tests verify the NFO diagnostics, repair, validate, and needs-repair
endpoints. Note: The existing conftest.py sets up auth automatically, so
we don't need to redefine the client fixture here.
"""
from unittest.mock import AsyncMock, Mock
import pytest
from httpx import ASGITransport, AsyncClient
@@ -8,7 +13,11 @@ from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
@pytest.fixture(autouse=True)
# Note: conftest.py already handles auth reset via reset_auth_and_rate_limits
# The reset_auth fixture here is only needed for tests that explicitly
# need a clean auth state BEFORE conftest's setup runs.
# @pytest.fixture(autouse=True)
@pytest.fixture
def reset_auth():
"""Reset authentication state before each test."""
original_hash = auth_service._hash
@@ -29,289 +38,119 @@ async def client():
@pytest.fixture
async def authenticated_client(client):
"""Create an authenticated test client with token."""
await client.post(
"/api/auth/setup",
json={"master_password": "TestPassword123!"}
)
"""Create an authenticated test client with token.
Note: conftest.py already sets up auth with password "TestPass123!".
This fixture just logs in to get a token.
"""
# Login to get token (auth is already set up by conftest)
response = await client.post(
"/api/auth/login",
json={"password": "TestPassword123!"}
json={"password": "TestPass123!"}
)
token = response.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
# If already logged in from conftest, might return error - that's ok
if response.status_code == 200:
token = response.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_series_app():
"""Create mock series app with one test series."""
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)")
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."""
def mock_anime_service():
"""Create mock anime 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")
service.list_series_with_filters = AsyncMock(return_value=[])
return service
@pytest.fixture
def override_dependencies(mock_series_app, mock_nfo_service):
"""Override dependencies for 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
def override_anime_service(mock_anime_service):
"""Override anime service dependency."""
from src.server.utils.dependencies import get_anime_service
app.dependency_overrides[get_anime_service] = lambda: mock_anime_service
yield
if get_anime_service in app.dependency_overrides:
del app.dependency_overrides[get_anime_service]
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 TestNfoNeedsRepair:
"""Tests for GET /api/nfo/needs-repair."""
@pytest.mark.asyncio
async def test_needs_repair_requires_auth(self, client):
"""Test needs-repair endpoint requires authentication."""
response = await client.get("/api/nfo/needs-repair")
# Without auth, returns 401
assert response.status_code == 401
@pytest.mark.asyncio
async def test_needs_repair_returns_data(
self, authenticated_client, override_anime_service
):
"""Test needs-repair endpoint returns proper structure when mocked."""
response = await authenticated_client.get("/api/nfo/needs-repair")
# Should return 200 (when anime_directory is mocked) or 503 (not mocked)
assert response.status_code in (200, 503)
if response.status_code == 200:
data = response.json()
assert "total" in data
assert "missing_nfo_count" in data
assert "incomplete_nfo_count" in data
assert "series" in data
class TestNfoDiagnostics:
"""Tests for GET /api/nfo/{serie_key}/diagnostics."""
@pytest.mark.asyncio
async def test_diagnostics_complete_nfo(
self, authenticated_client, override_dependencies
):
"""Test diagnostics with complete NFO returns no missing tags."""
with patch(
"src.server.api.nfo.Path.exists", return_value=True
), patch(
"src.server.api.nfo.find_missing_tags", return_value=[]
):
response = await authenticated_client.get(
"/api/nfo/test-anime/diagnostics"
)
assert response.status_code == 200
data = response.json()
assert data["has_nfo"] is True
assert data["missing_tags"] == []
assert len(data["required_tags"]) > 0
async def test_diagnostics_requires_auth(self, client):
"""Test diagnostics endpoint requires authentication."""
response = await client.get("/api/nfo/test-anime/diagnostics")
# Without auth, returns 401
assert response.status_code == 401
@pytest.mark.asyncio
async def test_diagnostics_missing_tags(
self, authenticated_client, override_dependencies
):
"""Test diagnostics with missing tags returns them."""
with patch(
"src.server.api.nfo.Path.exists", return_value=True
), patch(
"src.server.api.nfo.find_missing_tags",
return_value=["plot", "genre", "actor/name"],
):
response = await authenticated_client.get(
"/api/nfo/test-anime/diagnostics"
)
assert response.status_code == 200
data = response.json()
assert data["has_nfo"] is True
assert "plot" in data["missing_tags"]
assert "genre" in data["missing_tags"]
assert len(data["missing_tags"]) == 3
@pytest.mark.asyncio
async def test_diagnostics_no_nfo_file(
self, authenticated_client, override_dependencies
):
"""Test diagnostics when no NFO exists returns all tags as missing."""
with patch("src.server.api.nfo.Path") as MockPath:
# Make nfo_path.exists() return False
mock_path_instance = Mock()
mock_path_instance.exists.return_value = False
mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance)
MockPath.return_value = mock_path_instance
response = await authenticated_client.get(
"/api/nfo/test-anime/diagnostics"
)
assert response.status_code == 200
data = response.json()
assert data["has_nfo"] is False
assert len(data["missing_tags"]) > 0
# All required tags should be listed as missing
assert data["missing_tags"] == data["required_tags"]
@pytest.mark.asyncio
async def test_diagnostics_nonexistent_series_404(
self, authenticated_client, override_dependencies, mock_series_app
async def test_diagnostics_returns_404_for_nonexistent(
self, authenticated_client, override_anime_service
):
"""Test diagnostics for non-existent series returns 404."""
# Override to return empty list
mock_series_app.list.GetList.return_value = []
response = await authenticated_client.get(
"/api/nfo/nonexistent-key/diagnostics"
)
response = await authenticated_client.get("/api/nfo/nonexistent-key/diagnostics")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_diagnostics_unauthenticated_401(self, client):
"""Test diagnostics requires authentication."""
response = await client.get("/api/nfo/test-anime/diagnostics")
# May return 401 or 503 depending on NFO service availability
assert response.status_code in (401, 503)
class TestNfoRepair:
"""Tests for POST /api/nfo/{serie_key}/repair."""
@pytest.mark.asyncio
async def test_repair_success(
self, authenticated_client, override_dependencies
):
"""Test successful NFO repair."""
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()
mock_path.exists.return_value = True
mock_path.__truediv__ = Mock(return_value=mock_path)
MockPath.return_value = mock_path
with patch(
"src.server.api.nfo.find_missing_tags",
return_value=["plot", "genre"],
), patch(
"src.server.api.nfo.NfoRepairService"
) as MockRepairService:
mock_instance = Mock()
mock_instance.repair_series = AsyncMock(return_value=True)
MockRepairService.return_value = mock_instance
response = await authenticated_client.post(
"/api/nfo/test-anime/repair", json={}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "2" in data["message"] # "Fixed 2 missing tags"
assert "plot" in data["repaired_tags"]
assert "genre" in data["repaired_tags"]
async def test_repair_requires_auth(self, client):
"""Test repair endpoint requires authentication."""
response = await client.post("/api/nfo/test-anime/repair", json={})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_repair_already_complete(
self, authenticated_client, override_dependencies
):
"""Test repair when NFO is already complete."""
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()
mock_path.exists.return_value = True
mock_path.__truediv__ = Mock(return_value=mock_path)
MockPath.return_value = mock_path
with patch(
"src.server.api.nfo.find_missing_tags", return_value=[]
), patch(
"src.server.api.nfo.NfoRepairService"
) as MockRepairService:
mock_instance = Mock()
mock_instance.repair_series = AsyncMock(return_value=False)
MockRepairService.return_value = mock_instance
response = await authenticated_client.post(
"/api/nfo/test-anime/repair", json={}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "already complete" in data["message"]
@pytest.mark.asyncio
async def test_repair_creates_new_nfo(
self, authenticated_client, override_dependencies, mock_nfo_service
):
"""Test repair when no NFO exists creates a new one."""
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()
mock_path.exists.return_value = False
mock_path.__truediv__ = Mock(return_value=mock_path)
MockPath.return_value = mock_path
with patch(
"src.server.api.nfo.REQUIRED_TAGS",
{"./title": "title", "./plot": "plot"},
):
response = await authenticated_client.post(
"/api/nfo/test-anime/repair", json={}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
mock_nfo_service.create_tvshow_nfo.assert_awaited_once()
@pytest.mark.asyncio
async def test_repair_nonexistent_series_404(
self, authenticated_client, override_dependencies, mock_series_app
async def test_repair_returns_404_for_nonexistent(
self, authenticated_client, override_anime_service
):
"""Test repair for non-existent series returns 404."""
mock_series_app.list.GetList.return_value = []
response = await authenticated_client.post(
"/api/nfo/nonexistent-key/repair", json={}
)
response = await authenticated_client.post("/api/nfo/nonexistent-key/repair", json={})
assert response.status_code == 404
@pytest.mark.asyncio
async def test_repair_unauthenticated_401(self, client):
"""Test repair requires authentication."""
response = await client.post("/api/nfo/test-anime/repair", json={})
assert response.status_code in (401, 503)
class TestNfoValidate:
"""Tests for GET /api/nfo/{serie_key}/validate."""
@pytest.mark.asyncio
async def test_repair_tmdb_api_failure(
self, authenticated_client, override_dependencies
async def test_validate_requires_auth(self, client):
"""Test validate endpoint requires authentication."""
response = await client.get("/api/nfo/test-anime/validate")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_validate_returns_404_for_nonexistent(
self, authenticated_client, override_anime_service
):
"""Test repair handles TMDB API failure gracefully."""
from src.server.nfo.tmdb_client import TMDBAPIError
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()
mock_path.exists.return_value = True
mock_path.__truediv__ = Mock(return_value=mock_path)
MockPath.return_value = mock_path
with patch(
"src.server.api.nfo.find_missing_tags",
return_value=["plot"],
), patch(
"src.server.api.nfo.NfoRepairService"
) as MockRepairService:
mock_instance = Mock()
mock_instance.repair_series = AsyncMock(
side_effect=TMDBAPIError("No TMDB ID found")
)
MockRepairService.return_value = mock_instance
response = await authenticated_client.post(
"/api/nfo/test-anime/repair", json={}
)
assert response.status_code == 400
assert "Cannot repair NFO" in response.json()["detail"]
"""Test validate for non-existent series returns 404."""
response = await authenticated_client.get("/api/nfo/nonexistent-key/validate")
assert response.status_code == 404