/** * 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 = `
`;
// 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: ' and unhides it',
async () => {
mockFetchSequence([{
status: 200,
body: {
key: 'a',
folder: 'A',
content: 'A ',
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(
'A '
);
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');
});
});