refactor: remove Edit Metadata right-click option

Removes the Edit Metadata feature from the anime context menu and
deletes all related backend/frontend code:

Backend:
- DELETE PUT /api/anime/{anime_key} endpoint
- DELETE AnimeMetadataUpdate model and KEY_PATTERN regex
- DELETE MAX_INPUT_LENGTH constant

Frontend:
- DELETE edit-modal.js component
- DELETE edit metadata modal HTML from index.html
- DELETE edit-modal.js script tag
- DELETE edit-only CSS (.edit-modal-content, .edit-section,
  .field-error, .input-error, .key-warning, .info-grid,
  .info-item, .input-with-action, .btn-fetch-tmdb, .tmdb-*)
- REMOVE 'Edit Metadata' item from context menu (NFO Diagnostics
  preserved)

Tests:
- DELETE test_anime_edit_endpoints.py
- DELETE test_edit_modal.py
- DELETE test_anime_key_rename.py
This commit is contained in:
2026-06-20 21:02:22 +02:00
parent a8e54876e3
commit e050f6fa2d
9 changed files with 3 additions and 1589 deletions

View File

@@ -1,255 +0,0 @@
"""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

View File

@@ -1,115 +0,0 @@
"""Frontend tests for the edit metadata modal HTML structure."""
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 authenticated client to access index page."""
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}"})
# Set cookie for page access
client.cookies.set("access_token", token)
yield client
class TestEditModalHtmlPresence:
"""Tests verifying edit modal HTML elements exist in index page."""
@pytest.mark.asyncio
async def test_index_page_contains_edit_modal(self, authenticated_client):
"""Verify #edit-metadata-modal exists in rendered index page."""
response = await authenticated_client.get("/")
# Page may redirect or require different auth for HTML pages
if response.status_code == 200:
html = response.text
assert 'id="edit-metadata-modal"' in html
@pytest.mark.asyncio
async def test_index_page_loads_context_menu_script(self, authenticated_client):
"""Verify context-menu.js script tag is present."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert "context-menu.js" in html
@pytest.mark.asyncio
async def test_index_page_loads_edit_modal_script(self, authenticated_client):
"""Verify edit-modal.js script tag is present."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert "edit-modal.js" in html
@pytest.mark.asyncio
async def test_modal_form_fields_present(self, authenticated_client):
"""Verify key, tmdb_id, tvdb_id input fields exist in modal."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert 'id="edit-key"' in html
assert 'id="edit-tmdb-id"' in html
assert 'id="edit-tvdb-id"' in html
@pytest.mark.asyncio
async def test_nfo_repair_button_present(self, authenticated_client):
"""Verify repair NFO button exists in modal."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert 'id="btn-repair-nfo"' in html
@pytest.mark.asyncio
async def test_save_button_present(self, authenticated_client):
"""Verify save button exists in modal."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert 'id="btn-save-metadata"' in html
@pytest.mark.asyncio
async def test_modal_starts_hidden(self, authenticated_client):
"""Verify modal has hidden class by default."""
response = await authenticated_client.get("/")
if response.status_code == 200:
html = response.text
assert 'id="edit-metadata-modal" class="modal hidden"' in html

View File

@@ -1,161 +0,0 @@
"""Unit tests for anime key rename logic and validation."""
import pytest
from pydantic import ValidationError
from src.server.models.anime import AnimeMetadataUpdate, KEY_PATTERN
class TestKeyValidation:
"""Tests for AnimeMetadataUpdate key validation."""
def test_valid_key_simple(self):
"""Test simple valid key."""
model = AnimeMetadataUpdate(key="attack-on-titan")
assert model.key == "attack-on-titan"
def test_valid_key_single_char(self):
"""Test single character key is valid."""
model = AnimeMetadataUpdate(key="a")
assert model.key == "a"
def test_valid_key_numbers(self):
"""Test key with numbers."""
model = AnimeMetadataUpdate(key="86-eighty-six")
assert model.key == "86-eighty-six"
def test_valid_key_allows_hyphens(self):
"""Test hyphens in key are allowed."""
model = AnimeMetadataUpdate(key="my-anime-key")
assert model.key == "my-anime-key"
def test_valid_key_normalizes_to_lowercase(self):
"""Test key is normalized to lowercase."""
model = AnimeMetadataUpdate(key="Attack-On-Titan")
assert model.key == "attack-on-titan"
def test_valid_key_strips_whitespace(self):
"""Test key strips leading/trailing whitespace."""
model = AnimeMetadataUpdate(key=" my-key ")
assert model.key == "my-key"
def test_invalid_key_spaces(self):
"""Test key with spaces is rejected."""
with pytest.raises(ValidationError) as exc_info:
AnimeMetadataUpdate(key="my anime key")
assert "Key must contain only" in str(exc_info.value)
def test_invalid_key_uppercase_special(self):
"""Test key with special characters is rejected."""
with pytest.raises(ValidationError) as exc_info:
AnimeMetadataUpdate(key="anime!@#key")
assert "Key must contain only" in str(exc_info.value)
def test_invalid_key_empty(self):
"""Test empty key is rejected."""
with pytest.raises(ValidationError) as exc_info:
AnimeMetadataUpdate(key="")
assert "cannot be empty" in str(exc_info.value)
def test_invalid_key_only_whitespace(self):
"""Test whitespace-only key is rejected."""
with pytest.raises(ValidationError) as exc_info:
AnimeMetadataUpdate(key=" ")
assert "cannot be empty" in str(exc_info.value)
def test_invalid_key_starts_with_hyphen(self):
"""Test key starting with hyphen is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(key="-my-key")
def test_invalid_key_ends_with_hyphen(self):
"""Test key ending with hyphen is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(key="my-key-")
def test_key_none_is_allowed(self):
"""Test None key (no change requested) is allowed."""
model = AnimeMetadataUpdate(key=None)
assert model.key is None
def test_key_omitted_is_allowed(self):
"""Test omitting key entirely is allowed."""
model = AnimeMetadataUpdate(tmdb_id=1234)
assert model.key is None
class TestTmdbIdValidation:
"""Tests for tmdb_id validation."""
def test_valid_tmdb_id(self):
"""Test valid positive TMDB ID."""
model = AnimeMetadataUpdate(tmdb_id=1429)
assert model.tmdb_id == 1429
def test_tmdb_id_none(self):
"""Test None tmdb_id is allowed."""
model = AnimeMetadataUpdate(tmdb_id=None)
assert model.tmdb_id is None
def test_tmdb_id_negative_rejected(self):
"""Test negative tmdb_id is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(tmdb_id=-1)
def test_tmdb_id_zero_rejected(self):
"""Test zero tmdb_id is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(tmdb_id=0)
class TestTvdbIdValidation:
"""Tests for tvdb_id validation."""
def test_valid_tvdb_id(self):
"""Test valid positive TVDB ID."""
model = AnimeMetadataUpdate(tvdb_id=267440)
assert model.tvdb_id == 267440
def test_tvdb_id_none(self):
"""Test None tvdb_id is allowed."""
model = AnimeMetadataUpdate(tvdb_id=None)
assert model.tvdb_id is None
def test_tvdb_id_negative_rejected(self):
"""Test negative tvdb_id is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(tvdb_id=-5)
def test_tvdb_id_zero_rejected(self):
"""Test zero tvdb_id is rejected."""
with pytest.raises(ValidationError):
AnimeMetadataUpdate(tvdb_id=0)
class TestKeyPattern:
"""Tests for the KEY_PATTERN regex directly."""
@pytest.mark.parametrize("key", [
"a",
"abc",
"attack-on-titan",
"86-eighty-six",
"a1b2c3",
"x",
"1",
])
def test_valid_patterns(self, key):
"""Test keys that should match the pattern."""
assert KEY_PATTERN.match(key) is not None
@pytest.mark.parametrize("key", [
"-start",
"end-",
"has space",
"UPPER",
"special!char",
"under_score",
"",
])
def test_invalid_patterns(self, key):
"""Test keys that should not match the pattern."""
assert KEY_PATTERN.match(key) is None