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.
579 lines
22 KiB
JavaScript
579 lines
22 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),
|
|
},
|
|
UI: {
|
|
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.UI.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.UI.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.UI.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.UI.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.UI.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.UI.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('Cannot regenerate'),
|
|
'error'
|
|
);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// 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()
|
|
// -------------------------------------------------------------------
|
|
|
|
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.UI.showToast with success type', () => {
|
|
manager.showSaveSuccess('Saved!');
|
|
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
|
'Saved!', 'success'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('showError()', () => {
|
|
it('calls AniWorld.UI.showToast with error type', () => {
|
|
manager.showError('Boom');
|
|
expect(global.AniWorld.UI.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.viewNfoContent).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');
|
|
});
|
|
}); |