feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints

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
This commit is contained in:
2026-06-21 07:52:22 +02:00
parent e050f6fa2d
commit eabce18e41
22 changed files with 3216 additions and 2302 deletions

View File

@@ -0,0 +1,501 @@
/**
* 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');
});
});

View File

@@ -0,0 +1,167 @@
/**
* Unit tests for AniWorld.ContextMenu
*
* Covers the right-click → "Anime Settings" navigation flow including
* the regression where `hide()` was called BEFORE the navigation
* `window.location.href` was built, which caused the key to be reset
* to null and the URL to become `/anime/settings?key=null`.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const SRC_PATH = resolve(
__dirname,
'../../../src/server/web/static/js/index/context-menu.js',
);
function loadContextMenu() {
// Reset module state so each test gets a fresh closure.
delete global.AniWorld;
const src = readFileSync(SRC_PATH, 'utf8');
// Indirect eval → runs in global scope so `var AniWorld = window.AniWorld
// || {}` mutates the real `global.AniWorld` (and through it,
// `window.AniWorld` since happy-dom exposes global on window).
// eslint-disable-next-line no-eval
(0, eval)(src);
return global.AniWorld.ContextMenu;
}
describe('ContextMenu — right-click → Anime Settings flow', () => {
beforeEach(() => {
document.body.innerHTML = '';
delete window.AniWorld;
delete global.AniWorld;
delete window.location;
window.location = { href: '' };
});
afterEach(() => {
vi.restoreAllMocks();
});
it('navigates to /anime/settings?key=<series-key> after menu click', () => {
const ctx = loadContextMenu();
expect(ctx).toBeTruthy();
expect(typeof ctx.show).toBe('function');
const grid = document.createElement('div');
grid.id = 'series-grid';
const card = document.createElement('div');
card.className = 'series-card';
card.setAttribute('data-key', 'attack-on-titan');
grid.appendChild(card);
document.body.appendChild(grid);
ctx.init();
card.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: 10,
clientY: 10,
}),
);
const menuItem = document.querySelector(
'[data-action="anime-settings"]',
);
expect(menuItem).toBeTruthy();
menuItem.click();
expect(window.location.href).toBe(
'/anime/settings?key=attack-on-titan',
);
});
it('encodes special characters in the key (URL-unsafe slugs)', () => {
const ctx = loadContextMenu();
const grid = document.createElement('div');
grid.id = 'series-grid';
const card = document.createElement('div');
card.className = 'series-card';
card.setAttribute('data-key', 'a/b c');
grid.appendChild(card);
document.body.appendChild(grid);
ctx.init();
card.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: 5,
clientY: 5,
}),
);
document.querySelector('[data-action="anime-settings"]').click();
expect(window.location.href).toBe('/anime/settings?key=a%2Fb%20c');
});
it('source captures the key before calling hide() — regression guard', () => {
// Static invariant: the click handler must read currentSeriesKey
// BEFORE calling hide(). This guards against regressions of the
// bug where hide() cleared currentSeriesKey before the URL was
// built, resulting in /anime/settings?key=null.
const src = readFileSync(SRC_PATH, 'utf8');
const clickHandlerMatch = src.match(
/querySelector\('\[data-action="anime-settings"\]'\)\.addEventListener\('click',\s*function\s*\(\)\s*{([\s\S]*?)\}\);/,
);
expect(clickHandlerMatch, 'click handler should exist').toBeTruthy();
const body = clickHandlerMatch[1];
expect(body).toMatch(/currentSeriesKey/);
expect(body).toMatch(/\bhide\s*\(\s*\)/);
expect(body).toMatch(/const\s+key\s*=\s*currentSeriesKey/);
});
it('does not expose legacy nfo-diagnostics action', () => {
const ctx = loadContextMenu();
const grid = document.createElement('div');
grid.id = 'series-grid';
const card = document.createElement('div');
card.className = 'series-card';
card.setAttribute('data-key', 'k');
grid.appendChild(card);
document.body.appendChild(grid);
ctx.init();
card.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: 5,
clientY: 5,
}),
);
expect(
document.querySelector('[data-action="nfo-diagnostics"]'),
).toBeNull();
expect(
document.querySelector('[data-action="anime-settings"]'),
).toBeTruthy();
});
it('right-click outside a series card does not show the menu', () => {
const ctx = loadContextMenu();
const grid = document.createElement('div');
grid.id = 'series-grid';
document.body.appendChild(grid);
ctx.init();
// Click on empty grid area — should NOT show menu (no .series-card ancestor).
grid.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
clientX: 5,
clientY: 5,
}),
);
expect(document.querySelector('.context-menu')).toBeNull();
});
});