feat: add anime metadata editing and NFO diagnostics
- 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
This commit is contained in:
255
tests/api/test_anime_edit_endpoints.py
Normal file
255
tests/api/test_anime_edit_endpoints.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Tests for anime metadata edit (PUT /api/anime/{anime_key}) endpoint."""
|
||||
from unittest.mock import AsyncMock, MagicMock, 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
|
||||
def reset_auth():
|
||||
"""Reset auth state before each test."""
|
||||
auth_service._hash = None
|
||||
auth_service._failed = {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create 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):
|
||||
"""Get authenticated client with Bearer token."""
|
||||
# Setup auth
|
||||
await client.post("/api/auth/setup", json={"master_password": "TestPass123!"})
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"password": "TestPass123!"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
client.headers["Authorization"] = f"Bearer {token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Create a mock async database session."""
|
||||
session = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.flush = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_in_db():
|
||||
"""Create a mock AnimeSeries DB record."""
|
||||
series = MagicMock()
|
||||
series.id = 1
|
||||
series.key = "test-anime"
|
||||
series.name = "Test Anime"
|
||||
series.tmdb_id = 1234
|
||||
series.tvdb_id = 5678
|
||||
series.folder = "Test Anime (2023)"
|
||||
return series
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_db_dependency(mock_db_session):
|
||||
"""Override database session dependency."""
|
||||
from src.server.utils.dependencies import get_database_session
|
||||
|
||||
app.dependency_overrides[get_database_session] = lambda: mock_db_session
|
||||
yield mock_db_session
|
||||
app.dependency_overrides.pop(get_database_session, None)
|
||||
|
||||
|
||||
class TestUpdateAnimeMetadata:
|
||||
"""Tests for PUT /api/anime/{anime_key}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tmdb_id_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful tmdb_id update."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
), patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
mock_series_in_db.tmdb_id = 9999
|
||||
mock_update.return_value = mock_series_in_db
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": 9999},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tmdb_id"] == 9999
|
||||
assert data["message"] == "Metadata updated successfully"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tvdb_id_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful tvdb_id update."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
), patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
mock_series_in_db.tvdb_id = 7777
|
||||
mock_update.return_value = mock_series_in_db
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tvdb_id": 7777},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tvdb_id"] == 7777
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful key rename."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get:
|
||||
# First call finds the series, second call checks uniqueness (returns None)
|
||||
mock_get.side_effect = [mock_series_in_db, None]
|
||||
|
||||
mock_series_in_db.key = "new-anime-key"
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "new-anime-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == "new-anime-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_conflict_409(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test key rename conflict returns 409."""
|
||||
existing_series = MagicMock()
|
||||
existing_series.key = "existing-key"
|
||||
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get:
|
||||
# First call finds original series, second call finds conflict
|
||||
mock_get.side_effect = [mock_series_in_db, existing_series]
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "existing-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "already exists" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_invalid_chars_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test key with invalid characters returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "Invalid Key With Spaces!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_empty_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test empty key returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_unauthenticated_401(self, reset_auth, client):
|
||||
"""Test unauthenticated access returns 401."""
|
||||
response = await client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": 1234},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_anime_404(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test update of non-existent anime returns 404."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/nonexistent-key",
|
||||
json={"tmdb_id": 1234},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_no_changes(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test sending empty body returns no-op response."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "No changes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_negative_tmdb_id_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test negative TMDB ID returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": -5},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
Reference in New Issue
Block a user