diff --git a/src/server/api/anime.py b/src/server/api/anime.py index eaca467..ff7649a 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -18,7 +18,6 @@ from src.server.exceptions import ( ) from src.server.models.anime import ( AnimeDetailsResponse, - AnimeMetadataUpdate, TMDBSearchResult, ) from src.server.services.anime_service import AnimeService, AnimeServiceError @@ -1310,78 +1309,3 @@ async def search_tmdb_for_series( ] -# Maximum allowed input size for security -MAX_INPUT_LENGTH = 100000 # 100KB - - -@router.put("/{anime_key}") -async def update_anime_metadata( - anime_key: str, - body: AnimeMetadataUpdate, - _auth: dict = Depends(require_auth), - db: AsyncSession = Depends(get_database_session), -) -> dict: - """Update anime metadata (key, tmdb_id, tvdb_id). - - Args: - anime_key: Current series key to update - body: Fields to update (all optional) - _auth: Authentication dependency - db: Database session - - Returns: - Updated series metadata - - Raises: - HTTPException 404: Series not found - HTTPException 409: Key conflict (new key already exists) - HTTPException 422: Validation error - """ - series = await AnimeSeriesService.get_by_key(db, anime_key) - if not series: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series with key '{anime_key}' not found", - ) - - updates = {} - - if body.key is not None and body.key != anime_key: - existing = await AnimeSeriesService.get_by_key(db, body.key) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"A series with key '{body.key}' already exists", - ) - updates["key"] = body.key - - if body.tmdb_id is not None: - updates["tmdb_id"] = body.tmdb_id - - if body.tvdb_id is not None: - updates["tvdb_id"] = body.tvdb_id - - if not updates: - return { - "key": series.key, - "tmdb_id": series.tmdb_id, - "tvdb_id": series.tvdb_id, - "message": "No changes", - } - - updated = await AnimeSeriesService.update(db, series.id, **updates) - await db.commit() - - logger.info( - "Updated metadata for '%s': %s", - anime_key, - updates, - ) - - return { - "key": updated.key, - "tmdb_id": updated.tmdb_id, - "tvdb_id": updated.tvdb_id, - "message": "Metadata updated successfully", - } - diff --git a/src/server/models/anime.py b/src/server/models/anime.py index 5b45324..d3b400d 100644 --- a/src/server/models/anime.py +++ b/src/server/models/anime.py @@ -10,15 +10,11 @@ Note on identifiers: """ from __future__ import annotations -import re from datetime import datetime from typing import List, Optional from pydantic import BaseModel, Field, HttpUrl, field_validator -# Regex pattern for valid series keys (URL-safe, lowercase with hyphens) -KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$') - class EpisodeInfo(BaseModel): """Information about a single episode.""" @@ -83,30 +79,6 @@ class AnimeSeriesResponse(BaseModel): return v -class AnimeMetadataUpdate(BaseModel): - """Request model for updating anime metadata (key, tmdb_id, tvdb_id).""" - - key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)") - tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)") - tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)") - - @field_validator('key', mode='before') - @classmethod - def validate_key_format(cls, v: Optional[str]) -> Optional[str]: - """Validate key is URL-safe lowercase with hyphens only.""" - if v is None: - return v - v = v.strip().lower() - if not v: - raise ValueError("Key cannot be empty") - if not KEY_PATTERN.match(v): - raise ValueError( - "Key must contain only lowercase letters, numbers, and hyphens. " - "Cannot start or end with a hyphen." - ) - return v - - class SearchRequest(BaseModel): """Request payload for searching series.""" diff --git a/src/server/web/static/css/components/modals.css b/src/server/web/static/css/components/modals.css index 79c0a1d..fbbdd8c 100644 --- a/src/server/web/static/css/components/modals.css +++ b/src/server/web/static/css/components/modals.css @@ -312,97 +312,8 @@ } /* ============================================================================ - Edit Metadata Modal + NFO Diagnostics ============================================================================ */ - -.edit-modal-content { - max-width: 520px; -} - -.edit-section { - margin-bottom: var(--spacing-lg); - padding-bottom: var(--spacing-lg); - border-bottom: 1px solid var(--color-divider); -} - -.edit-section:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; -} - -.edit-section h4 { - margin: 0 0 var(--spacing-md) 0; - font-size: var(--font-size-body); - font-weight: 600; - color: var(--color-text-primary); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.edit-section h4 i { - color: var(--color-accent); -} - -.form-group { - margin-bottom: var(--spacing-md); -} - -.form-group label { - display: block; - margin-bottom: var(--spacing-xs); - font-size: var(--font-size-caption); - font-weight: 500; - color: var(--color-text-secondary); -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-lg); -} - -/* Edit modal specific input sizing */ -.edit-modal-content .input-field { - width: 100%; - min-width: 140px; -} - -.edit-modal-content .form-group { - min-width: 0; -} - -.edit-modal-content .form-row .form-group { - min-width: 0; -} - -.field-error { - display: block; - margin-top: var(--spacing-xs); - font-size: var(--font-size-caption); - color: var(--color-error, #e74c3c); -} - -.input-error { - border-color: var(--color-error, #e74c3c) !important; -} - -.key-warning { - background: rgba(255, 193, 7, 0.1); - border: 1px solid rgba(255, 193, 7, 0.3); - border-radius: var(--border-radius); - padding: var(--spacing-sm) var(--spacing-md); - margin-top: var(--spacing-sm); - font-size: var(--font-size-caption); - color: var(--color-warning, #f39c12); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -/* NFO Diagnostics */ -.nfo-diagnostics { display: flex; flex-direction: column; gap: var(--spacing-sm); @@ -504,101 +415,4 @@ flex-shrink: 0; } -/* Series Info Grid (Read-only display) */ -.info-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--spacing-sm) var(--spacing-lg); -} - -.info-item { - display: flex; - flex-direction: column; - gap: 2px; -} - -.info-item label { - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - font-weight: 500; -} - -.info-item .info-value { - font-size: var(--font-size-body); - color: var(--color-text-primary); -} - -/* Input with action button */ -.input-with-action { - display: flex; - gap: var(--spacing-sm); - align-items: center; -} - -.input-with-action .input-field { - flex: 1; -} - -.btn-fetch-tmdb { - flex-shrink: 0; - white-space: nowrap; -} - -/* TMDB Search Results */ -.tmdb-search-results { - margin-top: var(--spacing-sm); - border: 1px solid var(--color-border); - border-radius: var(--border-radius); - background: var(--color-surface); - max-height: 200px; - overflow-y: auto; -} - -.tmdb-result-item { - padding: var(--spacing-sm) var(--spacing-md); - cursor: pointer; - border-bottom: 1px solid var(--color-divider); - display: flex; - flex-direction: column; - gap: 2px; -} - -.tmdb-result-item:last-child { - border-bottom: none; -} - -.tmdb-result-item:hover { - background: var(--color-background-subtle); -} - -.tmdb-result-title { - font-weight: 500; - color: var(--color-text-primary); -} - -.tmdb-result-overview { - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tmdb-error, -.tmdb-no-results, -.tmdb-selected { - padding: var(--spacing-sm) var(--spacing-md); - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - margin: 0; -} - -.tmdb-error { - color: var(--color-error, #e74c3c); -} - -.tmdb-selected { - color: var(--color-success, #2ecc71); -} - diff --git a/src/server/web/static/js/index/context-menu.js b/src/server/web/static/js/index/context-menu.js index 18b3a65..7e43e73 100644 --- a/src/server/web/static/js/index/context-menu.js +++ b/src/server/web/static/js/index/context-menu.js @@ -2,9 +2,9 @@ * AniWorld - Context Menu Component * * Right-click context menu for anime series cards. - * Provides quick access to edit metadata. + * Provides quick access to NFO diagnostics. * - * Dependencies: ui-utils.js, edit-modal.js + * Dependencies: ui-utils.js */ var AniWorld = window.AniWorld || {}; @@ -67,10 +67,6 @@ AniWorld.ContextMenu = (function() { menuElement = document.createElement('div'); menuElement.className = 'context-menu'; menuElement.innerHTML = ` -
- - - @@ -799,7 +676,6 @@ - diff --git a/tests/api/test_anime_edit_endpoints.py b/tests/api/test_anime_edit_endpoints.py deleted file mode 100644 index f3bfddd..0000000 --- a/tests/api/test_anime_edit_endpoints.py +++ /dev/null @@ -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 diff --git a/tests/frontend/test_edit_modal.py b/tests/frontend/test_edit_modal.py deleted file mode 100644 index 97f3ecd..0000000 --- a/tests/frontend/test_edit_modal.py +++ /dev/null @@ -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 diff --git a/tests/unit/test_anime_key_rename.py b/tests/unit/test_anime_key_rename.py deleted file mode 100644 index 426a7fc..0000000 --- a/tests/unit/test_anime_key_rename.py +++ /dev/null @@ -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