Compare commits
4 Commits
b8892b4737
...
8887f9a7cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 8887f9a7cb | |||
|
|
2299cf788b | ||
|
|
9f52ea03fb | ||
| ff526e08ea |
25
Docs/API.md
25
Docs/API.md
@@ -1056,34 +1056,39 @@ Update existing NFO file with fresh TMDB data.
|
|||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L243-L325)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L243-L325)
|
||||||
|
|
||||||
### GET /api/nfo/{serie_id}/content
|
### GET /api/nfo/{key}/content
|
||||||
|
|
||||||
Get NFO file XML content for a series.
|
Read the raw `tvshow.nfo` XML for a series. Used by the Anime Settings
|
||||||
|
page's "View NFO XML" button to render the on-disk NFO in a `<pre>` block.
|
||||||
|
|
||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `key` (string): Series unique key (e.g., `attack-on-titan`)
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):** [`NfoContentResponse`](../src/server/models/nfo.py)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"serie_id": "one-piece",
|
"key": "attack-on-titan",
|
||||||
"serie_folder": "One Piece (1999)",
|
"folder": "Attack on Titan (2013)",
|
||||||
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tvshow>...</tvshow>",
|
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tvshow>...</tvshow>",
|
||||||
"file_size": 2048,
|
"file_size": 2048,
|
||||||
"last_modified": "2026-01-15T10:30:00"
|
"last_modified": "2026-09-04T17:42:13"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `400 Bad Request` — Series has no folder configured.
|
||||||
- `404 Not Found` - Series or NFO not found
|
- `401 Unauthorized` — Not authenticated.
|
||||||
|
- `404 Not Found` — Series with the given key does not exist, or its
|
||||||
|
`tvshow.nfo` is missing on disk.
|
||||||
|
- `500 Internal Server Error` — Failed to read the NFO file from disk.
|
||||||
|
- `503 Service Unavailable` — `settings.anime_directory` is not configured.
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L328-L397)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L411-L477)
|
||||||
|
|
||||||
### GET /api/nfo/{serie_id}/media/status
|
### GET /api/nfo/{serie_id}/media/status
|
||||||
|
|
||||||
|
|||||||
@@ -1650,17 +1650,11 @@ async def update_anime_settings(
|
|||||||
# Lazy-import to avoid heavy deps when not used
|
# Lazy-import to avoid heavy deps when not used
|
||||||
from src.server.api.nfo import _create_or_update_nfo
|
from src.server.api.nfo import _create_or_update_nfo
|
||||||
|
|
||||||
series_data = {
|
|
||||||
"key": anime_key,
|
|
||||||
"name": db_series.name,
|
|
||||||
"folder": db_series.folder,
|
|
||||||
"tmdb_id": db_series.tmdb_id,
|
|
||||||
}
|
|
||||||
await _create_or_update_nfo(
|
await _create_or_update_nfo(
|
||||||
key=anime_key,
|
key=anime_key,
|
||||||
folder=db_series.folder,
|
folder=db_series.folder,
|
||||||
tmdb_id=db_series.tmdb_id,
|
tmdb_id=db_series.tmdb_id,
|
||||||
series_data=series_data,
|
anime_service=anime_service,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1727,17 +1721,11 @@ async def regenerate_anime_nfo(
|
|||||||
try:
|
try:
|
||||||
from src.server.api.nfo import _create_or_update_nfo
|
from src.server.api.nfo import _create_or_update_nfo
|
||||||
|
|
||||||
series_data = {
|
|
||||||
"key": anime_key,
|
|
||||||
"name": db_series.name,
|
|
||||||
"folder": db_series.folder,
|
|
||||||
"tmdb_id": tmdb_id,
|
|
||||||
}
|
|
||||||
repaired_tags = await _create_or_update_nfo(
|
repaired_tags = await _create_or_update_nfo(
|
||||||
key=anime_key,
|
key=anime_key,
|
||||||
folder=db_series.folder,
|
folder=db_series.folder,
|
||||||
tmdb_id=tmdb_id,
|
tmdb_id=tmdb_id,
|
||||||
series_data=series_data,
|
anime_service=anime_service,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Provides endpoints for NFO settings, repair, and validation for anime series.
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -11,6 +12,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.models.nfo import (
|
from src.server.models.nfo import (
|
||||||
|
NfoContentResponse,
|
||||||
NfoRepairResponse,
|
NfoRepairResponse,
|
||||||
NfoSeriesSettings,
|
NfoSeriesSettings,
|
||||||
NfoSettingsResponse,
|
NfoSettingsResponse,
|
||||||
@@ -241,7 +243,6 @@ async def repair_nfo_settings(
|
|||||||
key=key,
|
key=key,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
tmdb_id=tmdb_id,
|
tmdb_id=tmdb_id,
|
||||||
series_data=series_data,
|
|
||||||
anime_service=anime_service,
|
anime_service=anime_service,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -285,7 +286,6 @@ async def _create_or_update_nfo(
|
|||||||
key: str,
|
key: str,
|
||||||
folder: str,
|
folder: str,
|
||||||
tmdb_id: int,
|
tmdb_id: int,
|
||||||
series_data: dict,
|
|
||||||
anime_service: AnimeService,
|
anime_service: AnimeService,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Create or update NFO file for a series.
|
"""Create or update NFO file for a series.
|
||||||
@@ -406,6 +406,74 @@ async def validate_nfo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{key}/content", response_model=NfoContentResponse)
|
||||||
|
async def get_nfo_content(
|
||||||
|
key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> NfoContentResponse:
|
||||||
|
"""Read and return the raw tvshow.nfo XML for a series.
|
||||||
|
|
||||||
|
Used by the Anime Settings page's "View NFO XML" button to display the
|
||||||
|
on-disk NFO contents inside a ``<pre>`` block. The XML is returned as
|
||||||
|
plain text inside a JSON wrapper so the same auth/header pipeline as the
|
||||||
|
other NFO endpoints can be reused.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series unique key
|
||||||
|
_auth: Authentication dependency
|
||||||
|
anime_service: AnimeService dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NfoContentResponse with raw XML in ``content``, the on-disk path,
|
||||||
|
file size and last-modified timestamp.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: If the series or its tvshow.nfo file is not found
|
||||||
|
HTTPException 400: If the series has no folder configured
|
||||||
|
HTTPException 503: If ``settings.anime_directory`` is not configured
|
||||||
|
"""
|
||||||
|
series_data = await _get_series_data(anime_service, key)
|
||||||
|
if not series_data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = series_data.get("folder", "")
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Series has no folder configured: {key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
nfo_path = _get_nfo_path(folder)
|
||||||
|
if not os.path.isfile(nfo_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No tvshow.nfo file found for series '{key}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stat = os.stat(nfo_path)
|
||||||
|
with open(nfo_path, "r", encoding="utf-8") as f:
|
||||||
|
xml_text = f.read()
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Failed to read NFO file %s: %s", nfo_path, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to read NFO file: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return NfoContentResponse(
|
||||||
|
key=key,
|
||||||
|
folder=folder,
|
||||||
|
content=xml_text,
|
||||||
|
file_size=stat.st_size,
|
||||||
|
last_modified=datetime.fromtimestamp(stat.st_mtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
|
@router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
|
||||||
async def get_series_needing_repair(
|
async def get_series_needing_repair(
|
||||||
_auth: dict = Depends(require_auth),
|
_auth: dict = Depends(require_auth),
|
||||||
@@ -522,7 +590,6 @@ async def batch_repair_nfo(
|
|||||||
key=key,
|
key=key,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
tmdb_id=tmdb_id,
|
tmdb_id=tmdb_id,
|
||||||
series_data=series_data,
|
|
||||||
anime_service=anime_service,
|
anime_service=anime_service,
|
||||||
)
|
)
|
||||||
results["success"] += 1
|
results["success"] += 1
|
||||||
|
|||||||
@@ -393,5 +393,29 @@ class NfoRepairResponse(BaseModel):
|
|||||||
message: str = Field(..., description="Human-readable result message")
|
message: str = Field(..., description="Human-readable result message")
|
||||||
repaired_tags: List[str] = Field(
|
repaired_tags: List[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Tags that were missing before repair"
|
description="Tags that were missing before repair",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NfoContentResponse(BaseModel):
|
||||||
|
"""Response containing the raw contents of a series' tvshow.nfo.
|
||||||
|
|
||||||
|
Returned by ``GET /api/nfo/{key}/content`` so the Anime Settings page
|
||||||
|
can render the XML for the user without exposing the on-disk path to
|
||||||
|
the client (only the resolved path is included for display).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: Series unique key the content was loaded for
|
||||||
|
folder: Series folder name (under ``settings.anime_directory``)
|
||||||
|
content: Raw XML text of tvshow.nfo (UTF-8)
|
||||||
|
file_size: Size of the NFO file in bytes
|
||||||
|
last_modified: ISO-8601 timestamp of last on-disk modification
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Series unique key")
|
||||||
|
folder: str = Field(..., description="Series folder name")
|
||||||
|
content: str = Field(..., description="Raw XML content of tvshow.nfo")
|
||||||
|
file_size: int = Field(..., description="NFO file size in bytes")
|
||||||
|
last_modified: datetime = Field(
|
||||||
|
..., description="Last modification time of the NFO file"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -300,6 +300,9 @@ AniWorld.DeleteModal = (function() {
|
|||||||
var result = await response.json();
|
var result = await response.json();
|
||||||
console.info('[DeleteModal] Delete succeeded:', result);
|
console.info('[DeleteModal] Delete succeeded:', result);
|
||||||
|
|
||||||
|
// Capture key before hide() nulls currentKey
|
||||||
|
var deletedKey = currentKey;
|
||||||
|
|
||||||
// Show success message based on what was deleted
|
// Show success message based on what was deleted
|
||||||
var msgParts = [];
|
var msgParts = [];
|
||||||
if (result.deleted_from_database) msgParts.push('removed from database');
|
if (result.deleted_from_database) msgParts.push('removed from database');
|
||||||
@@ -311,11 +314,17 @@ AniWorld.DeleteModal = (function() {
|
|||||||
: 'Delete completed.';
|
: 'Delete completed.';
|
||||||
|
|
||||||
AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning');
|
AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning');
|
||||||
|
|
||||||
|
// Close modal and reset submission state together — isSubmitting must
|
||||||
|
// be cleared before hide(), otherwise hide() bails out (early return
|
||||||
|
// on the !isSubmitting guard) and the modal stays visible.
|
||||||
|
isSubmitting = false;
|
||||||
|
if (confirmBtn) confirmBtn.textContent = 'Delete';
|
||||||
hide();
|
hide();
|
||||||
|
|
||||||
// Remove the card from the grid directly
|
// Remove the card from the grid directly
|
||||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||||
AniWorld.SeriesManager.removeSeries(currentKey);
|
AniWorld.SeriesManager.removeSeries(deletedKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* - loadSeries(key) : fetch settings for a series key
|
* - loadSeries(key) : fetch settings for a series key
|
||||||
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
|
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
|
||||||
* - regenerateNfo() : POST regenerate-nfo endpoint
|
* - regenerateNfo() : POST regenerate-nfo endpoint
|
||||||
|
* - viewNfoContent() : GET raw tvshow.nfo XML into the preview <pre>
|
||||||
* - validateField(name, value) : client-side validation, returns error string or null
|
* - validateField(name, value) : client-side validation, returns error string or null
|
||||||
* - populateForm(data) : fill the form from a payload
|
* - populateForm(data) : fill the form from a payload
|
||||||
* - showSaveSuccess(msg) : success toast
|
* - showSaveSuccess(msg) : success toast
|
||||||
@@ -632,6 +633,7 @@ AniWorld.AnimeSettingsManager = (function () {
|
|||||||
loadSeries: loadSeries,
|
loadSeries: loadSeries,
|
||||||
saveSettings: saveSettings,
|
saveSettings: saveSettings,
|
||||||
regenerateNfo: regenerateNfo,
|
regenerateNfo: regenerateNfo,
|
||||||
|
viewNfoContent: viewNfoContent,
|
||||||
validateField: validateField,
|
validateField: validateField,
|
||||||
populateForm: populateForm,
|
populateForm: populateForm,
|
||||||
showSaveSuccess: showSaveSuccess,
|
showSaveSuccess: showSaveSuccess,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Covers the live endpoints in src/server/api/nfo.py:
|
|||||||
- GET /api/nfo/{key}/diagnostics
|
- GET /api/nfo/{key}/diagnostics
|
||||||
- POST /api/nfo/{key}/repair
|
- POST /api/nfo/{key}/repair
|
||||||
- GET /api/nfo/{key}/validate
|
- GET /api/nfo/{key}/validate
|
||||||
|
- GET /api/nfo/{key}/content (re-introduced — used by Anime Settings 'View NFO XML')
|
||||||
- GET /api/nfo/needs-repair
|
- GET /api/nfo/needs-repair
|
||||||
- POST /api/nfo/batch/repair
|
- POST /api/nfo/batch/repair
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ Note: Legacy endpoints referenced in v1.x (e.g. /api/nfo/{id}/check,
|
|||||||
in the codebase — they were replaced by the consolidated diagnostic,
|
in the codebase — they were replaced by the consolidated diagnostic,
|
||||||
repair, validate, needs-repair, batch/repair endpoints and the new
|
repair, validate, needs-repair, batch/repair endpoints and the new
|
||||||
Anime Settings page (see tests/api/test_anime_settings_endpoints.py).
|
Anime Settings page (see tests/api/test_anime_settings_endpoints.py).
|
||||||
|
|
||||||
|
Auth note: tests/conftest.py's autouse ``reset_auth_and_rate_limits``
|
||||||
|
fixture configures the master password with ``TestPass123!`` before every
|
||||||
|
test. The per-file ``reset_auth`` autouse fixture that used to live here
|
||||||
|
was removed because it wiped the conftest's setup and made any test that
|
||||||
|
needed an authenticated client fail with a stale-hash login error.
|
||||||
"""
|
"""
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
@@ -19,16 +26,6 @@ import pytest
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from src.server.fastapi_app import app
|
from src.server.fastapi_app import app
|
||||||
from src.server.services.auth_service import auth_service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def reset_auth():
|
|
||||||
auth_service._hash = None
|
|
||||||
auth_service._failed.clear()
|
|
||||||
yield
|
|
||||||
auth_service._hash = None
|
|
||||||
auth_service._failed.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -38,19 +35,19 @@ async def client():
|
|||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
async def _login(client: AsyncClient) -> str:
|
||||||
async def authenticated_client(client):
|
"""Log in with the master password configured by conftest and
|
||||||
await client.post(
|
return the bearer token. Sets the ``Authorization`` header on the
|
||||||
"/api/auth/setup",
|
client as a side benefit so the caller can ``await client.get(...)``
|
||||||
json={"master_password": "TestPassword123!"},
|
immediately."""
|
||||||
)
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
json={"password": "TestPassword123!"},
|
json={"password": "TestPass123!"},
|
||||||
)
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
token = resp.json()["access_token"]
|
token = resp.json()["access_token"]
|
||||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
yield client
|
return token
|
||||||
|
|
||||||
|
|
||||||
class TestNFOAuthRequirements:
|
class TestNFOAuthRequirements:
|
||||||
@@ -84,6 +81,119 @@ class TestNFOAuthRequirements:
|
|||||||
)
|
)
|
||||||
assert resp.status_code in (401, 503)
|
assert resp.status_code in (401, 503)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_content_requires_auth(self, client):
|
||||||
|
"""GET /api/nfo/{key}/content (used by the Anime Settings page
|
||||||
|
'View NFO XML' button) must require authentication."""
|
||||||
|
resp = await client.get("/api/nfo/any-key/content")
|
||||||
|
assert resp.status_code in (401, 503)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNFOContentEndpoint:
|
||||||
|
"""Behavioural tests for GET /api/nfo/{key}/content.
|
||||||
|
|
||||||
|
Covers the success path and the two 404 cases (unknown series,
|
||||||
|
missing tvshow.nfo) the Anime Settings page relies on."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anime_service(self):
|
||||||
|
"""Replace the FastAPI get_anime_service dependency with a mock.
|
||||||
|
Yields the mock so individual tests can configure ``list_series_with_filters``."""
|
||||||
|
from src.server.utils import dependencies as deps
|
||||||
|
|
||||||
|
service = Mock()
|
||||||
|
service.list_series_with_filters = AsyncMock(return_value=[])
|
||||||
|
app.dependency_overrides[deps.get_anime_service] = lambda: service
|
||||||
|
yield service
|
||||||
|
app.dependency_overrides.pop(deps.get_anime_service, None)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_xml_for_series_with_nfo(
|
||||||
|
self, client, tmp_path, monkeypatch, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Happy path: existing tvshow.nfo is returned verbatim inside
|
||||||
|
the JSON wrapper the JS uses (``data.content``)."""
|
||||||
|
from src.config import settings as settings_module
|
||||||
|
|
||||||
|
# Point settings.anime_directory at a temp dir
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings_module.settings,
|
||||||
|
"anime_directory",
|
||||||
|
str(tmp_path),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build a fake folder + tvshow.nfo on disk
|
||||||
|
folder = "Naruto (2002)"
|
||||||
|
series_dir = tmp_path / folder
|
||||||
|
series_dir.mkdir()
|
||||||
|
xml = (
|
||||||
|
"<?xml version='1.0' encoding='UTF-8'?>\n"
|
||||||
|
"<tvshow><title>Naruto</title><year>2002</year></tvshow>\n"
|
||||||
|
)
|
||||||
|
(series_dir / "tvshow.nfo").write_text(xml, encoding="utf-8")
|
||||||
|
|
||||||
|
mock_anime_service.list_series_with_filters = AsyncMock(
|
||||||
|
return_value=[{"key": "naruto", "folder": folder}]
|
||||||
|
)
|
||||||
|
|
||||||
|
await _login(client)
|
||||||
|
|
||||||
|
resp = await client.get("/api/nfo/naruto/content")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["key"] == "naruto"
|
||||||
|
assert body["folder"] == folder
|
||||||
|
assert body["content"] == xml
|
||||||
|
assert body["file_size"] == len(xml.encode("utf-8"))
|
||||||
|
assert "last_modified" in body
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_404_when_series_unknown(
|
||||||
|
self, client, tmp_path, monkeypatch, mock_anime_service
|
||||||
|
):
|
||||||
|
from src.config import settings as settings_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings_module.settings,
|
||||||
|
"anime_directory",
|
||||||
|
str(tmp_path),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
mock_anime_service.list_series_with_filters = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
await _login(client)
|
||||||
|
|
||||||
|
resp = await client.get("/api/nfo/missing/content")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "not found" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_404_when_nfo_file_missing(
|
||||||
|
self, client, tmp_path, monkeypatch, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Series exists with a configured folder but no tvshow.nfo yet."""
|
||||||
|
from src.config import settings as settings_module
|
||||||
|
|
||||||
|
folder = "Empty"
|
||||||
|
(tmp_path / folder).mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings_module.settings,
|
||||||
|
"anime_directory",
|
||||||
|
str(tmp_path),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
mock_anime_service.list_series_with_filters = AsyncMock(
|
||||||
|
return_value=[{"key": "empty", "folder": folder}]
|
||||||
|
)
|
||||||
|
|
||||||
|
await _login(client)
|
||||||
|
|
||||||
|
resp = await client.get("/api/nfo/empty/content")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "tvshow.nfo" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
class TestNFOEndpointModels:
|
class TestNFOEndpointModels:
|
||||||
"""Verify the response models use the renamed classes (regression
|
"""Verify the response models use the renamed classes (regression
|
||||||
|
|||||||
@@ -318,6 +318,121 @@ class TestDeleteModalSeriesManagerIntegration:
|
|||||||
assert remove_called_with == [key]
|
assert remove_called_with == [key]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteModalModalCloseAfterSuccess:
|
||||||
|
"""Regression tests for the bug where the modal stayed visible after a
|
||||||
|
successful delete because isSubmitting was never reset on the success path.
|
||||||
|
|
||||||
|
Source-level tests (the JS is not run in pytest): they assert that the
|
||||||
|
handleConfirm success branch (a) clears isSubmitting before hide(), and
|
||||||
|
(b) removes the card via a key captured before hide() nulls currentKey.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_source():
|
||||||
|
import os
|
||||||
|
path = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
'..', '..',
|
||||||
|
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
|
||||||
|
)
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_isSubmitting_reset_on_success_path(self):
|
||||||
|
"""isSubmitting must be reset to false after a successful delete,
|
||||||
|
otherwise hide()'s early-return guard keeps the modal visible."""
|
||||||
|
src = self._read_source()
|
||||||
|
|
||||||
|
# Locate the success branch: it begins with "Delete succeeded:" log
|
||||||
|
success_idx = src.find("Delete succeeded:")
|
||||||
|
assert success_idx > 0, "Could not find Delete succeeded log line"
|
||||||
|
|
||||||
|
# Everything between the success log and the catch block belongs to
|
||||||
|
# the success path.
|
||||||
|
catch_idx = src.find("} catch (err)", success_idx)
|
||||||
|
assert catch_idx > 0, "Could not find catch block after success path"
|
||||||
|
|
||||||
|
success_branch = src[success_idx:catch_idx]
|
||||||
|
|
||||||
|
# The flag must be reset in this branch...
|
||||||
|
assert "isSubmitting = false" in success_branch, (
|
||||||
|
"isSubmitting is never reset on the success path — "
|
||||||
|
"this is the bug that left the modal visible with 'Deleting...'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ...BEFORE hide() is called.
|
||||||
|
reset_pos = success_branch.find("isSubmitting = false")
|
||||||
|
hide_pos = success_branch.find("hide();")
|
||||||
|
assert reset_pos > 0 and hide_pos > 0, (
|
||||||
|
"Could not locate isSubmitting reset or hide() call"
|
||||||
|
)
|
||||||
|
assert reset_pos < hide_pos, (
|
||||||
|
"isSubmitting must be cleared BEFORE hide() — otherwise hide()'s "
|
||||||
|
"guard (`if (isSubmitting) return`) bails out and the modal stays"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_modal_hidden_class_added_on_success(self):
|
||||||
|
"""After the success-path cleanup, hide() must run and apply the
|
||||||
|
'hidden' class. We verify by checking hide() is reached after the
|
||||||
|
isSubmitting reset (covered above) and that the reset precedes the
|
||||||
|
removal of the card from the grid."""
|
||||||
|
src = self._read_source()
|
||||||
|
|
||||||
|
success_idx = src.find("Delete succeeded:")
|
||||||
|
catch_idx = src.find("} catch (err)", success_idx)
|
||||||
|
success_branch = src[success_idx:catch_idx]
|
||||||
|
|
||||||
|
reset_pos = success_branch.find("isSubmitting = false")
|
||||||
|
hide_pos = success_branch.find("hide();")
|
||||||
|
remove_pos = success_branch.find("removeSeries(")
|
||||||
|
|
||||||
|
assert 0 < reset_pos < hide_pos < remove_pos, (
|
||||||
|
"Order on success path must be: "
|
||||||
|
"isSubmitting reset -> hide() -> removeSeries()"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_removeSeries_uses_captured_key_not_live_currentKey(self):
|
||||||
|
"""hide() nulls currentKey on line ~211. If removeSeries reads the
|
||||||
|
live currentKey AFTER hide(), it gets null and silently no-ops. The
|
||||||
|
fix captures the key into a local before hide() runs."""
|
||||||
|
src = self._read_source()
|
||||||
|
|
||||||
|
success_idx = src.find("Delete succeeded:")
|
||||||
|
catch_idx = src.find("} catch (err)", success_idx)
|
||||||
|
success_branch = src[success_idx:catch_idx]
|
||||||
|
|
||||||
|
# There must be a local capture of the key before hide().
|
||||||
|
assert "var deletedKey = currentKey;" in success_branch, (
|
||||||
|
"Success path must capture currentKey into a local before "
|
||||||
|
"hide() nulls it — otherwise removeSeries(currentKey) would be "
|
||||||
|
"a silent no-op."
|
||||||
|
)
|
||||||
|
|
||||||
|
# The removeSeries call must reference the captured local, not
|
||||||
|
# currentKey directly.
|
||||||
|
capture_pos = success_branch.find("var deletedKey = currentKey;")
|
||||||
|
remove_pos = success_branch.find("removeSeries(deletedKey)")
|
||||||
|
assert capture_pos > 0 and remove_pos > 0, (
|
||||||
|
"removeSeries must be called with the captured deletedKey"
|
||||||
|
)
|
||||||
|
assert capture_pos < remove_pos, (
|
||||||
|
"Capture must happen BEFORE removeSeries reads it"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_confirm_button_text_reset_on_success(self):
|
||||||
|
"""The button text is changed to 'Deleting...' during submit and must
|
||||||
|
be reverted to 'Delete' so the modal is in a clean state if reopened."""
|
||||||
|
src = self._read_source()
|
||||||
|
|
||||||
|
success_idx = src.find("Delete succeeded:")
|
||||||
|
catch_idx = src.find("} catch (err)", success_idx)
|
||||||
|
success_branch = src[success_idx:catch_idx]
|
||||||
|
|
||||||
|
assert "confirmBtn.textContent = 'Delete'" in success_branch, (
|
||||||
|
"Confirm button text must be reset to 'Delete' on the success path"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteModalConstants:
|
class TestDeleteModalConstants:
|
||||||
"""Tests for SERIES_DELETED WebSocket event constant."""
|
"""Tests for SERIES_DELETED WebSocket event constant."""
|
||||||
|
|
||||||
|
|||||||
@@ -375,6 +375,83 @@ describe('AnimeSettingsManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// viewNfoContent()
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('viewNfoContent()', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Seed currentKey via loadSeries so viewNfoContent has a key.
|
||||||
|
delete window.location;
|
||||||
|
window.location = { search: '?key=a', href: 'http://x/?key=a' };
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
key: 'a', name: 'A', folder: 'A', site: 's',
|
||||||
|
tmdb_id: null, tvdb_id: null, has_nfo: true,
|
||||||
|
nfo_path: '/anime/A/tvshow.nfo', episode_count: 0,
|
||||||
|
missing_episode_count: 0, loading_status: 'completed',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
await manager.loadSeries('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches /api/nfo/{key}/content with auth header', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
key: 'a',
|
||||||
|
folder: 'A',
|
||||||
|
content: '<tvshow><title>A</title></tvshow>',
|
||||||
|
file_size: 30,
|
||||||
|
last_modified: '2026-06-01T00:00:00',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await manager.viewNfoContent();
|
||||||
|
const [url, opts] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/nfo/a/content');
|
||||||
|
expect(opts.method).toBe('GET');
|
||||||
|
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the content into the #nfo-content <pre> and unhides it',
|
||||||
|
async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
key: 'a',
|
||||||
|
folder: 'A',
|
||||||
|
content: '<tvshow><title>A</title></tvshow>',
|
||||||
|
file_size: 30,
|
||||||
|
last_modified: '2026-06-01T00:00:00',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const pre = document.getElementById('nfo-content');
|
||||||
|
expect(pre.classList.contains('hidden')).toBe(true);
|
||||||
|
|
||||||
|
await manager.viewNfoContent();
|
||||||
|
expect(pre.textContent).toBe(
|
||||||
|
'<tvshow><title>A</title></tvshow>'
|
||||||
|
);
|
||||||
|
expect(pre.classList.contains('hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error toast when the backend returns 404', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 404,
|
||||||
|
ok: false,
|
||||||
|
body: { detail: 'Not Found' },
|
||||||
|
}]);
|
||||||
|
await manager.viewNfoContent();
|
||||||
|
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('NFO'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// validateField()
|
// validateField()
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -493,6 +570,7 @@ describe('AnimeSettingsManager', () => {
|
|||||||
expect(typeof manager.loadSeries).toBe('function');
|
expect(typeof manager.loadSeries).toBe('function');
|
||||||
expect(typeof manager.saveSettings).toBe('function');
|
expect(typeof manager.saveSettings).toBe('function');
|
||||||
expect(typeof manager.regenerateNfo).toBe('function');
|
expect(typeof manager.regenerateNfo).toBe('function');
|
||||||
|
expect(typeof manager.viewNfoContent).toBe('function');
|
||||||
expect(typeof manager.validateField).toBe('function');
|
expect(typeof manager.validateField).toBe('function');
|
||||||
expect(typeof manager.populateForm).toBe('function');
|
expect(typeof manager.populateForm).toBe('function');
|
||||||
expect(typeof manager.showSaveSuccess).toBe('function');
|
expect(typeof manager.showSaveSuccess).toBe('function');
|
||||||
|
|||||||
Reference in New Issue
Block a user