mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
4b90b35748
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
/**
|
|
* Component: Admin Settings Helpers Tests
|
|
* Documentation: documentation/settings-pages.md
|
|
*/
|
|
|
|
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/lib/utils/api', () => ({
|
|
fetchWithAuth: fetchWithAuthMock,
|
|
}));
|
|
|
|
const makeOk = () => ({ ok: true });
|
|
const makeFail = () => ({ ok: false });
|
|
|
|
const baseSettings = {
|
|
backendMode: 'plex',
|
|
hasLocalUsers: true,
|
|
hasLocalAdmins: true,
|
|
audibleRegion: 'us',
|
|
plex: { url: 'http://plex', token: 'token', libraryId: 'lib', triggerScanAfterImport: false },
|
|
audiobookshelf: { serverUrl: 'http://abs', apiToken: 'abs-token', libraryId: 'abs-lib', triggerScanAfterImport: false },
|
|
oidc: {
|
|
enabled: true,
|
|
providerName: 'OIDC',
|
|
issuerUrl: 'http://issuer',
|
|
clientId: 'client',
|
|
clientSecret: 'secret',
|
|
accessControlMethod: 'open',
|
|
accessGroupClaim: 'groups',
|
|
accessGroupValue: '',
|
|
allowedEmails: 'first@example.com, second@example.com',
|
|
allowedUsernames: 'alpha, beta',
|
|
adminClaimEnabled: false,
|
|
adminClaimName: 'groups',
|
|
adminClaimValue: '',
|
|
},
|
|
registration: { enabled: true, requireAdminApproval: false },
|
|
prowlarr: { url: 'http://prowlarr', apiKey: 'key' },
|
|
downloadClient: {
|
|
type: 'qbittorrent',
|
|
url: 'http://qb',
|
|
username: 'user',
|
|
password: 'pass',
|
|
disableSSLVerify: false,
|
|
remotePathMappingEnabled: false,
|
|
remotePath: '',
|
|
localPath: '',
|
|
},
|
|
paths: {
|
|
downloadDir: '/downloads',
|
|
mediaDir: '/media',
|
|
audiobookPathTemplate: '',
|
|
ebookPathTemplate: '',
|
|
metadataTaggingEnabled: true,
|
|
chapterMergingEnabled: false,
|
|
},
|
|
ebook: { enabled: false, preferredFormat: '', baseUrl: '', flaresolverrUrl: '' },
|
|
};
|
|
|
|
describe('admin settings helpers', () => {
|
|
it('parses array strings to comma-separated values', async () => {
|
|
const { parseArrayToCommaSeparated } = await import('@/app/admin/settings/lib/helpers');
|
|
expect(parseArrayToCommaSeparated('["a","b"]')).toBe('a, b');
|
|
expect(parseArrayToCommaSeparated('not-json')).toBe('');
|
|
});
|
|
|
|
it('parses comma-separated strings into JSON arrays', async () => {
|
|
const { parseCommaSeparatedToArray } = await import('@/app/admin/settings/lib/helpers');
|
|
expect(parseCommaSeparatedToArray('alpha, beta')).toBe('["alpha","beta"]');
|
|
expect(parseCommaSeparatedToArray('')).toBe('[]');
|
|
});
|
|
|
|
it('validates auth settings when no auth methods are enabled', async () => {
|
|
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
const result = validateAuthSettings({
|
|
...baseSettings,
|
|
backendMode: 'audiobookshelf',
|
|
hasLocalUsers: false,
|
|
hasLocalAdmins: false,
|
|
oidc: { ...baseSettings.oidc, enabled: false },
|
|
registration: { enabled: false, requireAdminApproval: false },
|
|
});
|
|
expect(result.valid).toBe(false);
|
|
expect(result.message).toContain('At least one authentication method must be enabled');
|
|
});
|
|
|
|
it('prevents saving when manual registration is enabled but no admin users exist', async () => {
|
|
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
const result = validateAuthSettings({
|
|
...baseSettings,
|
|
backendMode: 'audiobookshelf',
|
|
hasLocalUsers: false,
|
|
hasLocalAdmins: false,
|
|
oidc: { ...baseSettings.oidc, enabled: false },
|
|
registration: { enabled: true, requireAdminApproval: false },
|
|
});
|
|
expect(result.valid).toBe(false);
|
|
expect(result.message).toContain('no local admin users exist');
|
|
});
|
|
|
|
it('allows saving when manual registration is enabled and admin users exist', async () => {
|
|
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
const result = validateAuthSettings({
|
|
...baseSettings,
|
|
backendMode: 'audiobookshelf',
|
|
hasLocalUsers: true,
|
|
hasLocalAdmins: true,
|
|
oidc: { ...baseSettings.oidc, enabled: false },
|
|
registration: { enabled: true, requireAdminApproval: false },
|
|
});
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('allows saving when OIDC is enabled even without local admin users', async () => {
|
|
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
const result = validateAuthSettings({
|
|
...baseSettings,
|
|
backendMode: 'audiobookshelf',
|
|
hasLocalUsers: false,
|
|
hasLocalAdmins: false,
|
|
oidc: { ...baseSettings.oidc, enabled: true },
|
|
registration: { enabled: false, requireAdminApproval: false },
|
|
});
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('returns tab validation based on backend mode and changes', async () => {
|
|
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
|
|
const validated = {
|
|
plex: true,
|
|
audiobookshelf: false,
|
|
oidc: false,
|
|
registration: false,
|
|
prowlarr: false,
|
|
download: true,
|
|
paths: true,
|
|
};
|
|
|
|
expect(getTabValidation('library', baseSettings, baseSettings, validated)).toBe(true);
|
|
expect(getTabValidation('download', baseSettings, baseSettings, validated)).toBe(true);
|
|
|
|
const changed = { ...baseSettings, prowlarr: { url: 'new', apiKey: 'key' } };
|
|
expect(getTabValidation('prowlarr', changed, baseSettings, validated)).toBe(false);
|
|
});
|
|
|
|
it('returns true for auth tab when OIDC is disabled', async () => {
|
|
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
|
|
const validated = {
|
|
plex: false,
|
|
audiobookshelf: false,
|
|
oidc: false,
|
|
registration: false,
|
|
prowlarr: false,
|
|
download: false,
|
|
paths: false,
|
|
};
|
|
|
|
const settingsWithOidcDisabled = {
|
|
...baseSettings,
|
|
oidc: { ...baseSettings.oidc, enabled: false },
|
|
};
|
|
|
|
expect(getTabValidation('auth', settingsWithOidcDisabled, baseSettings, validated)).toBe(true);
|
|
});
|
|
|
|
it('returns false for auth tab when OIDC is enabled but not validated', async () => {
|
|
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
|
|
const validated = {
|
|
plex: false,
|
|
audiobookshelf: false,
|
|
oidc: false,
|
|
registration: false,
|
|
prowlarr: false,
|
|
download: false,
|
|
paths: false,
|
|
};
|
|
|
|
expect(getTabValidation('auth', baseSettings, baseSettings, validated)).toBe(false);
|
|
});
|
|
|
|
it('returns true for auth tab when OIDC is enabled and validated', async () => {
|
|
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
|
|
const validated = {
|
|
plex: false,
|
|
audiobookshelf: false,
|
|
oidc: true,
|
|
registration: false,
|
|
prowlarr: false,
|
|
download: false,
|
|
paths: false,
|
|
};
|
|
|
|
expect(getTabValidation('auth', baseSettings, baseSettings, validated)).toBe(true);
|
|
});
|
|
|
|
it('returns auth tabs for audiobookshelf mode', async () => {
|
|
const { getTabs } = await import('@/app/admin/settings/lib/helpers');
|
|
const absTabs = getTabs('audiobookshelf').map((tab) => tab.id);
|
|
const plexTabs = getTabs('plex').map((tab) => tab.id);
|
|
|
|
expect(absTabs).toContain('auth');
|
|
expect(plexTabs).not.toContain('auth');
|
|
});
|
|
|
|
it('saves plex settings when library tab is active', async () => {
|
|
fetchWithAuthMock
|
|
.mockResolvedValueOnce(makeOk())
|
|
.mockResolvedValueOnce(makeOk());
|
|
|
|
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
await saveTabSettings('library', baseSettings, [], []);
|
|
|
|
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
|
'/api/admin/settings/audible',
|
|
expect.objectContaining({ method: 'PUT' })
|
|
);
|
|
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
|
'/api/admin/settings/plex',
|
|
expect.objectContaining({ method: 'PUT' })
|
|
);
|
|
});
|
|
|
|
it('saves audiobookshelf settings when library tab is active', async () => {
|
|
fetchWithAuthMock
|
|
.mockResolvedValueOnce(makeOk())
|
|
.mockResolvedValueOnce(makeOk());
|
|
|
|
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
await saveTabSettings('library', { ...baseSettings, backendMode: 'audiobookshelf' }, [], []);
|
|
|
|
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
|
'/api/admin/settings/audiobookshelf',
|
|
expect.objectContaining({ method: 'PUT' })
|
|
);
|
|
});
|
|
|
|
it('saves auth settings with converted allowed lists', async () => {
|
|
fetchWithAuthMock
|
|
.mockResolvedValueOnce(makeOk())
|
|
.mockResolvedValueOnce(makeOk());
|
|
|
|
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
await saveTabSettings('auth', baseSettings, [], []);
|
|
|
|
const oidcBody = JSON.parse((fetchWithAuthMock.mock.calls[0][1] as RequestInit).body as string);
|
|
expect(oidcBody.allowedEmails).toBe('["first@example.com","second@example.com"]');
|
|
expect(oidcBody.allowedUsernames).toBe('["alpha","beta"]');
|
|
});
|
|
|
|
it('saves OIDC settings even when disabled', async () => {
|
|
fetchWithAuthMock
|
|
.mockResolvedValueOnce(makeOk())
|
|
.mockResolvedValueOnce(makeOk());
|
|
|
|
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
const settingsWithOidcDisabled = {
|
|
...baseSettings,
|
|
oidc: { ...baseSettings.oidc, enabled: false },
|
|
};
|
|
await saveTabSettings('auth', settingsWithOidcDisabled, [], []);
|
|
|
|
// Verify OIDC endpoint is called even when disabled
|
|
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
|
'/api/admin/settings/oidc',
|
|
expect.objectContaining({ method: 'PUT' })
|
|
);
|
|
|
|
const oidcBody = JSON.parse((fetchWithAuthMock.mock.calls[0][1] as RequestInit).body as string);
|
|
expect(oidcBody.enabled).toBe(false);
|
|
});
|
|
|
|
it('saves prowlarr settings with enabled indexers and flag configs', async () => {
|
|
fetchWithAuthMock
|
|
.mockResolvedValueOnce(makeOk())
|
|
.mockResolvedValueOnce(makeOk());
|
|
|
|
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
await saveTabSettings(
|
|
'prowlarr',
|
|
baseSettings,
|
|
[{ id: 1, name: 'Idx', protocol: 'torrent', priority: 1, seedingTimeMinutes: 10, rssEnabled: true, categories: [3030] }],
|
|
[{ id: 'flag-1', name: 'Flag', weight: 1 }]
|
|
);
|
|
|
|
const body = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
|
|
expect(body.indexers[0].enabled).toBe(true);
|
|
expect(body.flagConfigs).toHaveLength(1);
|
|
});
|
|
|
|
it('saves download and paths settings', async () => {
|
|
fetchWithAuthMock.mockResolvedValue(makeOk());
|
|
|
|
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
await saveTabSettings('download', baseSettings, [], []);
|
|
await saveTabSettings('paths', baseSettings, [], []);
|
|
|
|
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
|
'/api/admin/settings/download-client',
|
|
expect.objectContaining({ method: 'PUT' })
|
|
);
|
|
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
|
'/api/admin/settings/paths',
|
|
expect.objectContaining({ method: 'PUT' })
|
|
);
|
|
});
|
|
|
|
it('throws for unsupported tab types', async () => {
|
|
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
await expect(saveTabSettings('ebook', baseSettings, [], [])).rejects.toThrow('Unknown settings tab');
|
|
});
|
|
|
|
it('throws when a save request fails', async () => {
|
|
fetchWithAuthMock
|
|
.mockResolvedValueOnce(makeFail());
|
|
|
|
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
|
await expect(saveTabSettings('library', baseSettings, [], [])).rejects.toThrow('Failed to save Audible region');
|
|
});
|
|
});
|