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)
|
||||
|
||||
### 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
|
||||
|
||||
**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
|
||||
{
|
||||
"serie_id": "one-piece",
|
||||
"serie_folder": "One Piece (1999)",
|
||||
"key": "attack-on-titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tvshow>...</tvshow>",
|
||||
"file_size": 2048,
|
||||
"last_modified": "2026-01-15T10:30:00"
|
||||
"last_modified": "2026-09-04T17:42:13"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `401 Unauthorized` - Not authenticated
|
||||
- `404 Not Found` - Series or NFO not found
|
||||
- `400 Bad Request` — Series has no folder configured.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -1650,17 +1650,11 @@ async def update_anime_settings(
|
||||
# Lazy-import to avoid heavy deps when not used
|
||||
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(
|
||||
key=anime_key,
|
||||
folder=db_series.folder,
|
||||
tmdb_id=db_series.tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1727,17 +1721,11 @@ async def regenerate_anime_nfo(
|
||||
try:
|
||||
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(
|
||||
key=anime_key,
|
||||
folder=db_series.folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -4,6 +4,7 @@ Provides endpoints for NFO settings, repair, and validation for anime series.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -11,6 +12,7 @@ from pydantic import BaseModel
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.models.nfo import (
|
||||
NfoContentResponse,
|
||||
NfoRepairResponse,
|
||||
NfoSeriesSettings,
|
||||
NfoSettingsResponse,
|
||||
@@ -241,7 +243,6 @@ async def repair_nfo_settings(
|
||||
key=key,
|
||||
folder=folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -285,7 +286,6 @@ async def _create_or_update_nfo(
|
||||
key: str,
|
||||
folder: str,
|
||||
tmdb_id: int,
|
||||
series_data: dict,
|
||||
anime_service: AnimeService,
|
||||
) -> List[str]:
|
||||
"""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)
|
||||
async def get_series_needing_repair(
|
||||
_auth: dict = Depends(require_auth),
|
||||
@@ -522,7 +590,6 @@ async def batch_repair_nfo(
|
||||
key=key,
|
||||
folder=folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
results["success"] += 1
|
||||
|
||||
@@ -393,5 +393,29 @@ class NfoRepairResponse(BaseModel):
|
||||
message: str = Field(..., description="Human-readable result message")
|
||||
repaired_tags: List[str] = Field(
|
||||
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();
|
||||
console.info('[DeleteModal] Delete succeeded:', result);
|
||||
|
||||
// Capture key before hide() nulls currentKey
|
||||
var deletedKey = currentKey;
|
||||
|
||||
// Show success message based on what was deleted
|
||||
var msgParts = [];
|
||||
if (result.deleted_from_database) msgParts.push('removed from database');
|
||||
@@ -311,11 +314,17 @@ AniWorld.DeleteModal = (function() {
|
||||
: 'Delete completed.';
|
||||
|
||||
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();
|
||||
|
||||
// Remove the card from the grid directly
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||
AniWorld.SeriesManager.removeSeries(currentKey);
|
||||
AniWorld.SeriesManager.removeSeries(deletedKey);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* - loadSeries(key) : fetch settings for a series key
|
||||
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
|
||||
* - 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
|
||||
* - populateForm(data) : fill the form from a payload
|
||||
* - showSaveSuccess(msg) : success toast
|
||||
@@ -632,6 +633,7 @@ AniWorld.AnimeSettingsManager = (function () {
|
||||
loadSeries: loadSeries,
|
||||
saveSettings: saveSettings,
|
||||
regenerateNfo: regenerateNfo,
|
||||
viewNfoContent: viewNfoContent,
|
||||
validateField: validateField,
|
||||
populateForm: populateForm,
|
||||
showSaveSuccess: showSaveSuccess,
|
||||
|
||||
@@ -4,6 +4,7 @@ Covers the live endpoints in src/server/api/nfo.py:
|
||||
- GET /api/nfo/{key}/diagnostics
|
||||
- POST /api/nfo/{key}/repair
|
||||
- GET /api/nfo/{key}/validate
|
||||
- GET /api/nfo/{key}/content (re-introduced — used by Anime Settings 'View NFO XML')
|
||||
- GET /api/nfo/needs-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,
|
||||
repair, validate, needs-repair, batch/repair endpoints and the new
|
||||
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
|
||||
|
||||
@@ -19,16 +26,6 @@ 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():
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -38,19 +35,19 @@ async def client():
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"},
|
||||
)
|
||||
async def _login(client: AsyncClient) -> str:
|
||||
"""Log in with the master password configured by conftest and
|
||||
return the bearer token. Sets the ``Authorization`` header on the
|
||||
client as a side benefit so the caller can ``await client.get(...)``
|
||||
immediately."""
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"},
|
||||
json={"password": "TestPass123!"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
return token
|
||||
|
||||
|
||||
class TestNFOAuthRequirements:
|
||||
@@ -84,6 +81,119 @@ class TestNFOAuthRequirements:
|
||||
)
|
||||
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:
|
||||
"""Verify the response models use the renamed classes (regression
|
||||
|
||||
@@ -318,6 +318,121 @@ class TestDeleteModalSeriesManagerIntegration:
|
||||
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:
|
||||
"""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()
|
||||
// -------------------------------------------------------------------
|
||||
@@ -493,6 +570,7 @@ describe('AnimeSettingsManager', () => {
|
||||
expect(typeof manager.loadSeries).toBe('function');
|
||||
expect(typeof manager.saveSettings).toBe('function');
|
||||
expect(typeof manager.regenerateNfo).toBe('function');
|
||||
expect(typeof manager.viewNfoContent).toBe('function');
|
||||
expect(typeof manager.validateField).toBe('function');
|
||||
expect(typeof manager.populateForm).toBe('function');
|
||||
expect(typeof manager.showSaveSuccess).toBe('function');
|
||||
|
||||
Reference in New Issue
Block a user