- Add PUT /anime/{key} endpoint for updating anime key, tmdb_id, tvdb_id
- Add NFO diagnostics and repair endpoints (GET/POST /nfo/diagnostics)
- Add edit modal UI with context menu integration
- Add frontend JS modules for context-menu and edit-modal
- Add comprehensive tests for edit, rename, and NFO repair flows
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Tests for NFO diagnostics and repair API endpoints."""
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from src.server.fastapi_app import app
|
|
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._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
|
|
|
|
|
|
@pytest.fixture
|
|
async def authenticated_client(client):
|
|
"""Create an authenticated test client with token."""
|
|
await client.post(
|
|
"/api/auth/setup",
|
|
json={"master_password": "TestPassword123!"}
|
|
)
|
|
response = await client.post(
|
|
"/api/auth/login",
|
|
json={"password": "TestPassword123!"}
|
|
)
|
|
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."""
|
|
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_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
|
|
|
|
yield
|
|
|
|
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 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
|
|
|
|
@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
|
|
):
|
|
"""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"
|
|
)
|
|
|
|
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"]
|
|
|
|
@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
|
|
):
|
|
"""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={}
|
|
)
|
|
|
|
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)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repair_tmdb_api_failure(
|
|
self, authenticated_client, override_dependencies
|
|
):
|
|
"""Test repair handles TMDB API failure gracefully."""
|
|
from src.core.services.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"]
|