Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.
Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
update_series_nfo_status(); now uses update_nfo_status() and an
explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
GET /settings -> AnimeSettingsResponse
PUT /settings -> AnimeSettingsResponse
(body: name/folder/tmdb_id/tvdb_id/site,
options: apply_to_nfo, rename_disk)
POST /regenerate-nfo -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it
Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
'anime-settings' (label 'Anime Settings'), navigates to
/anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'
Bug fix
- context-menu click handler was calling hide() BEFORE building the
navigation URL, which cleared currentSeriesKey to null and produced
/anime/settings?key=null. Captures the key into a local const first.
Regression-locked by tests/frontend/unit/context_menu.test.js.
Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
(GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)
Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix
Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
501 lines
20 KiB
JavaScript
501 lines
20 KiB
JavaScript
/**
|
|
* Unit tests for AniWorld.AnimeSettingsManager
|
|
*
|
|
* Tests every public function on the per-anime settings page JS module:
|
|
* - init() : binds DOM events, starts initial load
|
|
* - loadSeries(key) : fetches /api/anime/{key}/settings
|
|
* - saveSettings(opts) : PUTs /api/anime/{key}/settings
|
|
* - regenerateNfo() : POSTs /api/anime/{key}/regenerate-nfo
|
|
* - validateField(name, value) : client-side validation
|
|
* - populateForm(data) : fills form from payload
|
|
* - showSaveSuccess(msg) : success toast
|
|
* - showError(msg) : error toast
|
|
*
|
|
* Also verifies the auth header is included on every fetch.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Polyfill fetch globally (Vitest JSDOM env provides it but stub for clarity)
|
|
function mockFetchSequence(responses) {
|
|
let callIndex = 0;
|
|
global.fetch = vi.fn(async () => {
|
|
const r = responses[callIndex++];
|
|
if (!r) {
|
|
throw new Error('Unexpected fetch call');
|
|
}
|
|
return {
|
|
ok: r.ok !== false,
|
|
status: r.status || 200,
|
|
json: async () => r.body || {},
|
|
text: async () => r.text || JSON.stringify(r.body || {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
function readModuleSource() {
|
|
// Load the AnimeSettingsManager source via fs and eval inside a
|
|
// window-like scope. This mirrors the production IIFE pattern.
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const src = fs.readFileSync(
|
|
path.resolve(__dirname, '../../../src/server/web/static/js/pages/anime-settings.js'),
|
|
'utf8'
|
|
);
|
|
// Execute in global scope
|
|
// eslint-disable-next-line no-eval
|
|
(0, eval)(src);
|
|
return global.AniWorld.AnimeSettingsManager;
|
|
}
|
|
|
|
describe('AnimeSettingsManager', () => {
|
|
let manager;
|
|
|
|
beforeEach(() => {
|
|
// Build a minimal DOM tree covering every id the module touches
|
|
document.body.innerHTML = `
|
|
<div id="no-key-section" class="hidden"></div>
|
|
<div id="loading-section" class="hidden"></div>
|
|
<div id="error-section" class="hidden"></div>
|
|
<div id="settings-section" class="hidden"></div>
|
|
|
|
<select id="series-select"></select>
|
|
<button id="load-series-btn"></button>
|
|
<button id="retry-btn"></button>
|
|
|
|
<p id="error-message"></p>
|
|
<h2 id="series-display-name"></h2>
|
|
<span id="badge-loading-status"></span>
|
|
<span id="badge-has-nfo"></span>
|
|
<span id="badge-episode-counts"></span>
|
|
|
|
<code id="overview-key"></code>
|
|
<span id="overview-year"></span>
|
|
<span id="overview-loading-status"></span>
|
|
<span id="overview-episode-count"></span>
|
|
<span id="overview-missing-count"></span>
|
|
<span id="overview-nfo-created"></span>
|
|
<span id="overview-nfo-updated"></span>
|
|
<code id="overview-nfo-path"></code>
|
|
|
|
<input type="text" id="field-name" />
|
|
<input type="text" id="field-folder" />
|
|
<input type="number" id="field-tmdb-id" />
|
|
<input type="number" id="field-tvdb-id" />
|
|
<input type="text" id="field-site" />
|
|
|
|
<small id="hint-name"></small>
|
|
<small id="hint-folder"></small>
|
|
<small id="hint-tmdb-id"></small>
|
|
<small id="hint-tvdb-id"></small>
|
|
<small id="hint-site"></small>
|
|
|
|
<button id="save-db-btn"></button>
|
|
<button id="save-db-nfo-btn"></button>
|
|
<button id="reset-btn"></button>
|
|
<input type="checkbox" id="rename-disk-toggle" />
|
|
|
|
<button id="regenerate-nfo-btn"></button>
|
|
<button id="view-nfo-btn"></button>
|
|
<pre id="nfo-content" class="hidden"></pre>
|
|
`;
|
|
|
|
// Provide the shared helpers the module expects
|
|
global.AniWorld = {
|
|
Auth: {
|
|
getToken: vi.fn(() => 'fake-jwt-token'),
|
|
checkAuth: vi.fn().mockResolvedValue(true),
|
|
},
|
|
UiUtils: {
|
|
showToast: vi.fn(),
|
|
},
|
|
};
|
|
|
|
// Load module
|
|
manager = readModuleSource();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// init()
|
|
// -------------------------------------------------------------------
|
|
|
|
describe('init()', () => {
|
|
it('reads ?key= from URL and calls loadSeries', async () => {
|
|
mockFetchSequence([{
|
|
status: 200,
|
|
body: { key: 'aot', name: 'AOT', tmdb_id: 1 },
|
|
}]);
|
|
|
|
// Replace window.location with a controllable mock
|
|
delete window.location;
|
|
window.location = { search: '?key=aot', href: 'http://x/anime/settings?key=aot' };
|
|
|
|
manager.init();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
const url = global.fetch.mock.calls[0][0];
|
|
expect(url).toContain('/api/anime/aot/settings');
|
|
});
|
|
|
|
it('shows no-key section when no ?key is present', async () => {
|
|
delete window.location;
|
|
window.location = { search: '', href: 'http://x/anime/settings' };
|
|
|
|
// Stub populateSeriesSelect to avoid network
|
|
global.fetch = vi.fn(async () => ({
|
|
ok: true, status: 200,
|
|
json: async () => [],
|
|
text: async () => '[]',
|
|
}));
|
|
|
|
manager.init();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const section = document.getElementById('no-key-section');
|
|
expect(section.classList.contains('hidden')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// loadSeries()
|
|
// -------------------------------------------------------------------
|
|
|
|
describe('loadSeries()', () => {
|
|
it('calls fetch with auth header', async () => {
|
|
mockFetchSequence([{
|
|
status: 200,
|
|
body: {
|
|
key: 'naruto',
|
|
name: 'Naruto',
|
|
site: 'aniworld.to',
|
|
folder: 'Naruto (2002)',
|
|
year: 2002,
|
|
tmdb_id: 20,
|
|
tvdb_id: null,
|
|
has_nfo: true,
|
|
nfo_path: '/anime/Naruto/tvshow.nfo',
|
|
episode_count: 5,
|
|
missing_episode_count: 2,
|
|
loading_status: 'completed',
|
|
},
|
|
}]);
|
|
|
|
await manager.loadSeries('naruto');
|
|
const [url, opts] = global.fetch.mock.calls[0];
|
|
expect(url).toBe('/api/anime/naruto/settings');
|
|
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
|
|
});
|
|
|
|
it('populates the form on success', async () => {
|
|
mockFetchSequence([{
|
|
status: 200,
|
|
body: {
|
|
key: 'naruto',
|
|
name: 'Naruto',
|
|
site: 'aniworld.to',
|
|
folder: 'Naruto (2002)',
|
|
year: 2002,
|
|
tmdb_id: 20,
|
|
tvdb_id: null,
|
|
has_nfo: true,
|
|
nfo_path: '/anime/Naruto/tvshow.nfo',
|
|
episode_count: 5,
|
|
missing_episode_count: 2,
|
|
loading_status: 'completed',
|
|
},
|
|
}]);
|
|
|
|
await manager.loadSeries('naruto');
|
|
expect(document.getElementById('field-name').value).toBe('Naruto');
|
|
expect(document.getElementById('field-folder').value).toBe('Naruto (2002)');
|
|
expect(document.getElementById('field-tmdb-id').value).toBe('20');
|
|
});
|
|
|
|
it('handles 404 by showing the error section', async () => {
|
|
mockFetchSequence([{ status: 404, body: { detail: 'not found' } }]);
|
|
await manager.loadSeries('missing');
|
|
expect(
|
|
document.getElementById('error-section').classList.contains('hidden')
|
|
).toBe(false);
|
|
});
|
|
|
|
it('handles 401 by calling showError', async () => {
|
|
mockFetchSequence([{ status: 401, body: { detail: 'unauthorized' } }]);
|
|
await manager.loadSeries('whatever');
|
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('authenticated'),
|
|
'error'
|
|
);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// saveSettings()
|
|
// -------------------------------------------------------------------
|
|
|
|
describe('saveSettings()', () => {
|
|
beforeEach(async () => {
|
|
// First, set currentKey via loadSeries (matches URL-based init)
|
|
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: false,
|
|
nfo_path: null, episode_count: 0, missing_episode_count: 0,
|
|
loading_status: 'completed',
|
|
},
|
|
}]);
|
|
await manager.loadSeries('a');
|
|
|
|
// Now overwrite the form values with what we want to save.
|
|
// (loadSeries populates form from server, but we want to test
|
|
// that saveSettings sends the user-typed values, so we mutate
|
|
// them AFTER the load.)
|
|
document.getElementById('field-name').value = 'New Name';
|
|
document.getElementById('field-folder').value = 'New Folder';
|
|
document.getElementById('field-tmdb-id').value = '1234';
|
|
document.getElementById('field-tvdb-id').value = '';
|
|
document.getElementById('field-site').value = 'https://x';
|
|
});
|
|
|
|
it('sends PUT with auth header and JSON body', async () => {
|
|
mockFetchSequence([{
|
|
status: 200,
|
|
body: {
|
|
key: 'a',
|
|
name: 'New Name',
|
|
folder: 'New Folder',
|
|
tmdb_id: 1234,
|
|
},
|
|
}]);
|
|
|
|
await manager.saveSettings({ applyToNfo: false });
|
|
|
|
const [url, opts] = global.fetch.mock.calls[0];
|
|
expect(url).toBe('/api/anime/a/settings');
|
|
expect(opts.method).toBe('PUT');
|
|
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
|
|
expect(opts.headers['Content-Type']).toBe('application/json');
|
|
const body = JSON.parse(opts.body);
|
|
expect(body.name).toBe('New Name');
|
|
expect(body.folder).toBe('New Folder');
|
|
// form inputs return strings; the module passes them through
|
|
// verbatim — the server coerces to int.
|
|
expect(String(body.tmdb_id)).toBe('1234');
|
|
expect(body.apply_to_nfo).toBe(false);
|
|
});
|
|
|
|
it('shows success toast on save', async () => {
|
|
mockFetchSequence([{
|
|
status: 200,
|
|
body: { key: 'a', name: 'New Name' },
|
|
}]);
|
|
await manager.saveSettings({ applyToNfo: false });
|
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('saved'),
|
|
'success'
|
|
);
|
|
});
|
|
|
|
it('shows "regenerated" message when applyToNfo=true', async () => {
|
|
mockFetchSequence([{
|
|
status: 200,
|
|
body: { key: 'a', name: 'New Name', has_nfo: true },
|
|
}]);
|
|
await manager.saveSettings({ applyToNfo: true });
|
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('regenerated'),
|
|
'success'
|
|
);
|
|
});
|
|
|
|
it('shows error toast on 422', async () => {
|
|
mockFetchSequence([{ status: 422, body: { detail: 'bad tmdb_id' } }]);
|
|
await manager.saveSettings({ applyToNfo: false });
|
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('Validation'),
|
|
'error'
|
|
);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// regenerateNfo()
|
|
// -------------------------------------------------------------------
|
|
|
|
describe('regenerateNfo()', () => {
|
|
beforeEach(async () => {
|
|
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: false,
|
|
nfo_path: null, episode_count: 0, missing_episode_count: 0,
|
|
loading_status: 'completed',
|
|
},
|
|
}]);
|
|
await manager.loadSeries('a');
|
|
});
|
|
|
|
it('calls POST /regenerate-nfo and shows success toast', async () => {
|
|
mockFetchSequence([{
|
|
status: 200,
|
|
body: {
|
|
success: true,
|
|
message: 'NFO regenerated.',
|
|
repaired_tags: ['title'],
|
|
},
|
|
}]);
|
|
|
|
await manager.regenerateNfo();
|
|
const [url, opts] = global.fetch.mock.calls[0];
|
|
expect(url).toBe('/api/anime/a/regenerate-nfo');
|
|
expect(opts.method).toBe('POST');
|
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
|
'NFO regenerated.',
|
|
'success'
|
|
);
|
|
});
|
|
|
|
it('shows error toast on 400 (no tmdb_id)', async () => {
|
|
mockFetchSequence([{ status: 400, body: { detail: 'no TMDB ID' } }]);
|
|
await manager.regenerateNfo();
|
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('Cannot regenerate'),
|
|
'error'
|
|
);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// validateField()
|
|
// -------------------------------------------------------------------
|
|
|
|
describe('validateField()', () => {
|
|
it('rejects empty name', () => {
|
|
expect(manager.validateField('name', '')).toMatch(/empty/i);
|
|
expect(manager.validateField('name', null)).toMatch(/empty/i);
|
|
});
|
|
it('rejects too-long name', () => {
|
|
expect(manager.validateField('name', 'x'.repeat(501))).toMatch(/exceeds/);
|
|
});
|
|
it('accepts valid name', () => {
|
|
expect(manager.validateField('name', 'Naruto')).toBeNull();
|
|
});
|
|
it('rejects folder with path traversal', () => {
|
|
expect(manager.validateField('folder', '../etc')).toMatch(/path traversal/i);
|
|
});
|
|
it('rejects folder with invalid characters', () => {
|
|
expect(manager.validateField('folder', 'foo\x00bar')).toMatch(/invalid/i);
|
|
});
|
|
it('accepts tmdb_id as integer string', () => {
|
|
expect(manager.validateField('tmdb_id', '12345')).toBeNull();
|
|
});
|
|
it('rejects tmdb_id = "abc"', () => {
|
|
expect(manager.validateField('tmdb_id', 'abc')).toMatch(/integer/i);
|
|
});
|
|
it('rejects negative tmdb_id', () => {
|
|
expect(manager.validateField('tmdb_id', '-5')).toMatch(/positive/i);
|
|
});
|
|
it('rejects oversized tmdb_id', () => {
|
|
expect(manager.validateField('tmdb_id', '99999999999')).toMatch(/10 digits/i);
|
|
});
|
|
it('accepts empty tvdb_id (optional)', () => {
|
|
expect(manager.validateField('tvdb_id', '')).toBeNull();
|
|
expect(manager.validateField('tvdb_id', undefined)).toBeNull();
|
|
});
|
|
it('rejects negative tvdb_id', () => {
|
|
expect(manager.validateField('tvdb_id', '-1')).toMatch(/positive/i);
|
|
});
|
|
it('accepts valid site', () => {
|
|
expect(manager.validateField('site', 'https://aniworld.to')).toBeNull();
|
|
});
|
|
it('rejects too-long site', () => {
|
|
expect(manager.validateField('site', 'x'.repeat(501))).toMatch(/exceeds/);
|
|
});
|
|
it('returns null for unknown field name', () => {
|
|
expect(manager.validateField('mystery_field', 'anything')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// populateForm()
|
|
// -------------------------------------------------------------------
|
|
|
|
describe('populateForm()', () => {
|
|
it('sets all overview and form fields', () => {
|
|
manager.populateForm({
|
|
key: 'a',
|
|
name: 'A',
|
|
site: 'aniworld.to',
|
|
folder: 'A (2020)',
|
|
year: 2020,
|
|
tmdb_id: 100,
|
|
tvdb_id: 200,
|
|
has_nfo: true,
|
|
nfo_path: '/anime/A/tvshow.nfo',
|
|
episode_count: 12,
|
|
missing_episode_count: 3,
|
|
loading_status: 'completed',
|
|
});
|
|
expect(document.getElementById('field-name').value).toBe('A');
|
|
expect(document.getElementById('field-folder').value).toBe('A (2020)');
|
|
expect(document.getElementById('field-tmdb-id').value).toBe('100');
|
|
expect(document.getElementById('field-tvdb-id').value).toBe('200');
|
|
expect(document.getElementById('overview-key').textContent).toBe('a');
|
|
expect(document.getElementById('overview-year').textContent).toBe('2020');
|
|
});
|
|
|
|
it('handles missing optional fields gracefully', () => {
|
|
manager.populateForm({ key: 'a', name: 'A' });
|
|
expect(document.getElementById('field-tmdb-id').value).toBe('');
|
|
expect(document.getElementById('field-tvdb-id').value).toBe('');
|
|
expect(document.getElementById('field-name').value).toBe('A');
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// showSaveSuccess() / showError()
|
|
// -------------------------------------------------------------------
|
|
|
|
describe('showSaveSuccess()', () => {
|
|
it('calls AniWorld.UiUtils.showToast with success type', () => {
|
|
manager.showSaveSuccess('Saved!');
|
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
|
'Saved!', 'success'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('showError()', () => {
|
|
it('calls AniWorld.UiUtils.showToast with error type', () => {
|
|
manager.showError('Boom');
|
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
|
'Boom', 'error'
|
|
);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Public API surface
|
|
// -------------------------------------------------------------------
|
|
|
|
it('exposes all expected public methods', () => {
|
|
expect(typeof manager.init).toBe('function');
|
|
expect(typeof manager.loadSeries).toBe('function');
|
|
expect(typeof manager.saveSettings).toBe('function');
|
|
expect(typeof manager.regenerateNfo).toBe('function');
|
|
expect(typeof manager.validateField).toBe('function');
|
|
expect(typeof manager.populateForm).toBe('function');
|
|
expect(typeof manager.showSaveSuccess).toBe('function');
|
|
expect(typeof manager.showError).toBe('function');
|
|
});
|
|
}); |