fix(anime-settings): restore GET /api/nfo/{key}/content for 'View NFO XML' button
The Anime Settings page (src/server/web/static/js/pages/anime-settings.js)
calls GET /api/nfo/{key}/content from its 'View NFO XML' button, but that
endpoint was removed during the NFO refactor (commits 21af502, a8e5487).
The frontend was never updated, so every click on the button 404'd.
Fix:
- Add NfoContentResponse model (key, folder, content, file_size,
last_modified) to src/server/models/nfo.py.
- Add GET /api/nfo/{key}/content handler to src/server/api/nfo.py that
reads <anime_directory>/<folder>/tvshow.nfo and returns it as
{"content": "<xml>", ...} — matching what anime-settings.js
viewNfoContent() already expects (data.content).
- Expose viewNfoContent on AniWorld.AnimeSettingsManager so it is
consistent with the other public methods and directly callable from
tests / other modules.
Tests:
- tests/api/test_nfo_endpoints.py: 4 new tests (auth-required, happy
path returning XML, 404 on unknown series, 404 on missing tvshow.nfo).
Also remove the file-local autouse 'reset_auth' fixture that wiped
the conftest's master-password setup and made any login-based test
fail with a stale-hash 'invalid credentials' error — that fixture
was pre-existing and is a no-op now that conftest.py handles reset.
- tests/frontend/unit/anime_settings.test.js: 3 new tests for
viewNfoContent (URL + auth header, writes <pre>, error toast on
404) and an assertion in the public-API surface test.
This commit is contained in:
@@ -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,
|
||||||
@@ -406,6 +408,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),
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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