Implement file hash-based library matching and remove fuzzy ASIN matching

Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
This commit is contained in:
kikootwo
2026-01-28 10:32:14 -05:00
parent 497849f427
commit a97979358f
111 changed files with 6571 additions and 1426 deletions
@@ -0,0 +1,52 @@
/**
* Component: Active Downloads Table Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ActiveDownloadsTable } from '@/app/admin/components/ActiveDownloadsTable';
describe('ActiveDownloadsTable', () => {
afterEach(() => {
vi.useRealTimers();
});
it('renders an empty state when no downloads exist', () => {
render(<ActiveDownloadsTable downloads={[]} />);
expect(screen.getByText('No Active Downloads')).toBeInTheDocument();
});
it('renders download details with formatted values', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
render(
<ActiveDownloadsTable
downloads={[
{
requestId: 'req-1',
title: 'Active Book',
author: 'Author One',
progress: 42,
speed: 1024 * 1024,
eta: 3600,
user: 'Zach',
startedAt: new Date('2023-12-31T23:00:00Z'),
},
]}
/>
);
expect(screen.getByText('Active Book')).toBeInTheDocument();
expect(screen.getByText('Author One')).toBeInTheDocument();
expect(screen.getByText('42%')).toBeInTheDocument();
expect(screen.getByText('1 MB/s')).toBeInTheDocument();
expect(screen.getByText('1h 0m')).toBeInTheDocument();
expect(screen.getByText(/ago/)).toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
/**
* Component: Confirm Dialog Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
describe('ConfirmDialog', () => {
it('renders nothing when closed', () => {
render(
<ConfirmDialog
isOpen={false}
title="Delete"
message="Confirm?"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>
);
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
it('invokes confirm and cancel actions', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
const { container } = render(
<ConfirmDialog
isOpen
title="Delete"
message="Confirm?"
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
const backdrop = container.querySelector('[aria-hidden="true"]');
expect(backdrop).not.toBeNull();
if (backdrop) {
fireEvent.click(backdrop);
}
expect(onCancel).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,30 @@
/**
* Component: Metric Card Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MetricCard } from '@/app/admin/components/MetricCard';
describe('MetricCard', () => {
it('renders title, value, and subtitle with variant styles', () => {
const { container } = render(
<MetricCard
title="Errors"
value={3}
subtitle="Last 24h"
variant="error"
icon={<span>!</span>}
/>
);
expect(screen.getByText('Errors')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('Last 24h')).toBeInTheDocument();
expect(container.firstChild).toHaveClass('bg-red-50');
});
});
@@ -0,0 +1,173 @@
/**
* Component: Recent Requests Table Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import path from 'path';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
const mutateMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
vi.mock('swr', () => ({
mutate: mutateMock,
}));
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => toastMock,
}));
let RecentRequestsTable: typeof import('@/app/admin/components/RecentRequestsTable').RecentRequestsTable;
describe('RecentRequestsTable', () => {
beforeEach(async () => {
vi.resetModules();
fetchWithAuthMock.mockReset();
mutateMock.mockReset();
toastMock.success.mockReset();
toastMock.error.mockReset();
toastMock.warning.mockReset();
vi.doMock(path.resolve('src/app/admin/components/RequestActionsDropdown.tsx'), () => ({
RequestActionsDropdown: ({
request,
onDelete,
onManualSearch,
onCancel,
onFetchEbook,
isLoading,
}: {
request: { requestId: string; title: string };
onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => void;
onCancel: (requestId: string) => void;
onFetchEbook?: (requestId: string) => void;
isLoading?: boolean;
}) => (
<div>
<button type="button" onClick={() => onDelete(request.requestId, request.title)}>
Delete Trigger
</button>
<button type="button" onClick={() => onManualSearch(request.requestId)}>
Manual Search Trigger
</button>
<button type="button" onClick={() => onCancel(request.requestId)}>
Cancel Trigger
</button>
<button
type="button"
onClick={() => onFetchEbook?.(request.requestId)}
disabled={isLoading}
>
Fetch Ebook Trigger
</button>
</div>
),
}));
const module = await import('@/app/admin/components/RecentRequestsTable');
RecentRequestsTable = module.RecentRequestsTable;
});
it('shows empty state when there are no requests', () => {
render(<RecentRequestsTable requests={[]} />);
expect(screen.getByText('No Recent Requests')).toBeInTheDocument();
});
it('deletes a request and refreshes caches', async () => {
fetchWithAuthMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
render(
<RecentRequestsTable
requests={[
{
requestId: 'req-1',
title: 'Delete Me',
author: 'Author',
status: 'pending',
user: 'User',
createdAt: new Date('2024-01-01T00:00:00Z'),
completedAt: null,
errorMessage: null,
},
]}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Delete Trigger' }));
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/requests/req-1', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
});
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/recent');
expect(mutateMock).toHaveBeenCalledWith('/api/admin/metrics');
const predicateCall = mutateMock.mock.calls.find(
(call) => typeof call[0] === 'function'
);
expect(predicateCall).toBeTruthy();
const predicate = predicateCall?.[0] as (key: unknown) => boolean;
expect(predicate('/api/audiobooks?query=test')).toBe(true);
expect(predicate('/api/other')).toBe(false);
});
it('warns when ebook fetch fails', async () => {
fetchWithAuthMock.mockResolvedValue({
ok: true,
json: async () => ({ success: false, message: 'No ebook available' }),
});
render(
<RecentRequestsTable
requests={[
{
requestId: 'req-2',
title: 'Needs Ebook',
author: 'Author',
status: 'downloaded',
user: 'User',
createdAt: new Date('2024-01-01T00:00:00Z'),
completedAt: null,
errorMessage: null,
},
]}
ebookSidecarEnabled
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Fetch Ebook Trigger' }));
await waitFor(() => {
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-2/fetch-ebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(toastMock.warning).toHaveBeenCalledWith(
'E-book fetch failed: No ebook available'
);
});
});
});
@@ -0,0 +1,106 @@
/**
* Component: Request Actions Dropdown Tests
* Documentation: documentation/admin-features/request-deletion.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RequestActionsDropdown } from '@/app/admin/components/RequestActionsDropdown';
vi.mock('@/hooks/useSmartDropdownPosition', () => ({
useSmartDropdownPosition: () => ({
containerRef: { current: null },
dropdownRef: { current: null },
positionAbove: false,
style: { position: 'fixed', top: 0, left: 0, minWidth: 120 },
}),
}));
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
InteractiveTorrentSearchModal: ({
isOpen,
audiobook,
}: {
isOpen: boolean;
audiobook: { title: string; author: string };
}) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null),
}));
describe('RequestActionsDropdown', () => {
it('exposes manual search, interactive search, cancel, and delete actions', async () => {
const onManualSearch = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn().mockResolvedValue(undefined);
const onDelete = vi.fn();
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(
<RequestActionsDropdown
request={{
requestId: 'req-1',
title: 'Pending Book',
author: 'Author',
status: 'pending',
}}
onManualSearch={onManualSearch}
onCancel={onCancel}
onDelete={onDelete}
/>
);
fireEvent.click(screen.getByTitle('Actions'));
expect(screen.getByText('Manual Search')).toBeInTheDocument();
expect(screen.getByText('Interactive Search')).toBeInTheDocument();
expect(screen.getByText('Cancel Request')).toBeInTheDocument();
expect(screen.getByText('Delete Request')).toBeInTheDocument();
fireEvent.click(screen.getByText('Manual Search'));
await waitFor(() => expect(onManualSearch).toHaveBeenCalledWith('req-1'));
fireEvent.click(screen.getByTitle('Actions'));
fireEvent.click(screen.getByText('Interactive Search'));
expect(screen.getByText('Interactive search for Pending Book')).toBeInTheDocument();
fireEvent.click(screen.getByTitle('Actions'));
fireEvent.click(screen.getByText('Cancel Request'));
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('req-1'));
fireEvent.click(screen.getByTitle('Actions'));
fireEvent.click(screen.getByText('Delete Request'));
expect(onDelete).toHaveBeenCalledWith('req-1', 'Pending Book');
});
it('shows view source and ebook fetch when available', async () => {
const onFetchEbook = vi.fn().mockResolvedValue(undefined);
const onDelete = vi.fn();
render(
<RequestActionsDropdown
request={{
requestId: 'req-2',
title: 'Downloaded Book',
author: 'Author',
status: 'downloaded',
torrentUrl: 'https://example.com/torrent',
}}
onManualSearch={vi.fn().mockResolvedValue(undefined)}
onCancel={vi.fn().mockResolvedValue(undefined)}
onDelete={onDelete}
onFetchEbook={onFetchEbook}
ebookSidecarEnabled
/>
);
fireEvent.click(screen.getByTitle('Actions'));
expect(screen.getByText('View Source')).toBeInTheDocument();
expect(screen.getByText('Try to fetch Ebook')).toBeInTheDocument();
fireEvent.click(screen.getByText('Try to fetch Ebook'));
await waitFor(() => expect(onFetchEbook).toHaveBeenCalledWith('req-2'));
});
});
@@ -0,0 +1,156 @@
/**
* Component: Admin Settings Global Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const baseSettings = {
backendMode: 'plex',
hasLocalUsers: true,
audibleRegion: 'us',
plex: { url: '', token: '', libraryId: '', triggerScanAfterImport: false },
audiobookshelf: { serverUrl: '', apiToken: '', libraryId: '', triggerScanAfterImport: false },
oidc: {
enabled: false,
providerName: '',
issuerUrl: '',
clientId: '',
clientSecret: '',
accessControlMethod: 'open',
accessGroupClaim: 'groups',
accessGroupValue: '',
allowedEmails: '["first@example.com","second@example.com"]',
allowedUsernames: '["alpha","beta"]',
adminClaimEnabled: false,
adminClaimName: 'groups',
adminClaimValue: '',
},
registration: { enabled: false, requireAdminApproval: false },
prowlarr: { url: '', apiKey: '' },
downloadClient: {
type: 'qbittorrent',
url: '',
username: '',
password: '',
disableSSLVerify: false,
remotePathMappingEnabled: false,
remotePath: '',
localPath: '',
},
paths: {
downloadDir: '',
mediaDir: '',
audiobookPathTemplate: '',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
},
ebook: { enabled: false, preferredFormat: '', baseUrl: '', flaresolverrUrl: '' },
};
describe('useSettings', () => {
beforeEach(() => {
fetchWithAuthMock.mockReset();
});
it('loads settings and converts OIDC lists to comma-separated strings', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => baseSettings,
});
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
const result = renderHook(() => useSettings());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.settings?.oidc.allowedEmails).toBe('first@example.com, second@example.com');
expect(result.current.settings?.oidc.allowedUsernames).toBe('alpha, beta');
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/settings');
});
it('tracks changes, resets, and marks settings as saved', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => baseSettings,
});
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
const result = renderHook(() => useSettings());
await waitFor(() => expect(result.current.settings).not.toBeNull());
act(() => {
result.current.updateSettings({ audibleRegion: 'uk' });
});
expect(result.current.hasUnsavedChanges()).toBe(true);
act(() => {
result.current.resetSettings();
});
expect(result.current.settings?.audibleRegion).toBe('us');
expect(result.current.hasUnsavedChanges()).toBe(false);
act(() => {
result.current.updateSettings((prev) => ({ ...prev, audibleRegion: 'ca' }));
});
expect(result.current.hasUnsavedChanges()).toBe(true);
act(() => {
result.current.markAsSaved();
});
expect(result.current.hasUnsavedChanges()).toBe(false);
});
it('updates validation, test results, and message state', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => baseSettings,
});
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
const result = renderHook(() => useSettings());
await waitFor(() => expect(result.current.settings).not.toBeNull());
act(() => {
result.current.updateValidation('plex', true);
result.current.updateTestResults('plex', { success: true, message: 'ok' });
result.current.showMessage({ type: 'success', text: 'Saved' });
});
expect(result.current.validated.plex).toBe(true);
expect(result.current.testResults.plex).toEqual({ success: true, message: 'ok' });
expect(result.current.message?.text).toBe('Saved');
act(() => {
result.current.clearMessage();
});
expect(result.current.message).toBeNull();
});
});
@@ -0,0 +1,322 @@
/**
* 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: '',
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');
});
});
@@ -0,0 +1,133 @@
/**
* Component: Auth Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const makeResponse = (body: any, ok = true) => ({
ok,
json: async () => body,
});
describe('useAuthSettings', () => {
const onSuccess = vi.fn();
const onError = vi.fn();
beforeEach(() => {
fetchWithAuthMock.mockReset();
onSuccess.mockReset();
onError.mockReset();
});
it('fetches pending users successfully', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({ users: [{ id: 'u1', plexUsername: 'Pending' }] })
);
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
await result.current.fetchPendingUsers();
});
expect(result.current.pendingUsers).toHaveLength(1);
expect(result.current.loadingPendingUsers).toBe(false);
});
it('tests OIDC configuration successfully', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: true }));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
const ok = await result.current.testOIDCConnection('issuer', 'client', 'secret');
expect(ok).toBe(true);
});
expect(result.current.oidcTestResult?.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('OIDC configuration is valid. You can now save.');
});
it('surfaces OIDC validation errors', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: false, error: 'Bad issuer' }));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
const ok = await result.current.testOIDCConnection('issuer', 'client', 'secret');
expect(ok).toBe(false);
});
expect(result.current.oidcTestResult?.message).toBe('Bad issuer');
expect(onError).toHaveBeenCalledWith('Bad issuer');
});
it('approves a pending user and refreshes the list', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ success: true, message: 'Approved' }))
.mockResolvedValueOnce(makeResponse({ users: [] }));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
await result.current.approveUser('u1', true);
});
expect(onSuccess).toHaveBeenCalledWith('Approved');
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/users/u1/approve', expect.any(Object));
expect(result.current.pendingUsers).toHaveLength(0);
});
it('surfaces approval failures', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: false, error: 'Nope' }));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
await result.current.approveUser('u2', false);
});
expect(onError).toHaveBeenCalledWith('Nope');
});
it('handles pending user fetch errors gracefully', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({}, false));
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
await act(async () => {
await result.current.fetchPendingUsers();
});
await waitFor(() => {
expect(result.current.loadingPendingUsers).toBe(false);
});
});
});
@@ -0,0 +1,325 @@
/**
* Component: BookDate Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const makeResponse = (body: any, ok = true) => ({
ok,
json: async () => body,
});
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
describe('useBookDateSettings', () => {
beforeEach(() => {
fetchWithAuthMock.mockReset();
vi.unstubAllGlobals();
});
it('loads BookDate config on mount', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({
config: {
provider: 'claude',
model: 'claude-3',
baseUrl: 'http://custom',
isEnabled: false,
isVerified: true,
},
}));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(result.current.provider).toBe('claude'));
expect(result.current.model).toBe('claude-3');
expect(result.current.baseUrl).toBe('http://custom');
expect(result.current.enabled).toBe(false);
expect(result.current.configured).toBe(true);
});
it('validates missing API key for non-custom providers', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
const onSuccess = vi.fn();
const onError = vi.fn();
await act(async () => {
await result.current.testConnection(onSuccess, onError);
});
expect(onError).toHaveBeenCalledWith('Please enter an API key');
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
});
it('validates missing base URL for custom providers', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setProvider('custom');
});
const onError = vi.fn();
await act(async () => {
await result.current.testConnection(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Please enter a base URL for custom provider');
});
it('tests connection with saved key and auto-selects the first model', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({
config: {
provider: 'openai',
model: '',
baseUrl: '',
isEnabled: true,
isVerified: true,
},
}))
.mockResolvedValueOnce(makeResponse({
models: [{ id: 'gpt-4' }, { id: 'gpt-3.5' }],
}));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(result.current.configured).toBe(true));
const onSuccess = vi.fn();
const onError = vi.fn();
await act(async () => {
await result.current.testConnection(onSuccess, onError);
});
const requestBody = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
expect(requestBody.useSavedKey).toBe(true);
expect(requestBody.provider).toBe('openai');
expect(result.current.models).toHaveLength(2);
expect(result.current.model).toBe('gpt-4');
expect(onSuccess).toHaveBeenCalledWith('Connection successful! Please select a model.');
});
it('surfaces connection test errors', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({ error: 'Bad key' }, false));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setApiKey('key');
});
const onError = vi.fn();
await act(async () => {
await result.current.testConnection(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Bad key');
});
it('validates missing model before saving', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
const onError = vi.fn();
await act(async () => {
await result.current.saveConfig(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Please select a model');
});
it('validates custom base URL before saving', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setProvider('custom');
result.current.setModel('custom-model');
});
const onError = vi.fn();
await act(async () => {
await result.current.saveConfig(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Please enter a base URL for custom provider');
});
it('validates API key for initial setup before saving', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setModel('gpt-4');
});
const onError = vi.fn();
await act(async () => {
await result.current.saveConfig(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Please enter an API key for initial setup');
});
it('saves configuration and clears API key', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({}));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setModel('gpt-4');
result.current.setApiKey('secret');
result.current.setEnabled(false);
});
const onSuccess = vi.fn();
await act(async () => {
await result.current.saveConfig(onSuccess, vi.fn());
});
expect(onSuccess).toHaveBeenCalledWith('BookDate configuration saved successfully!');
expect(result.current.configured).toBe(true);
expect(result.current.apiKey).toBe('');
});
it('surfaces save errors', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({ error: 'Save failed' }, false));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
act(() => {
result.current.setModel('gpt-4');
result.current.setApiKey('secret');
});
const onError = vi.fn();
await act(async () => {
await result.current.saveConfig(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Save failed');
});
it('skips clearing swipes when confirmation is canceled', async () => {
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.clearSwipes(vi.fn(), vi.fn());
});
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
});
it('clears swipes after confirmation', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({}));
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
const onSuccess = vi.fn();
await act(async () => {
await result.current.clearSwipes(onSuccess, vi.fn());
});
expect(onSuccess).toHaveBeenCalledWith('Swipe history cleared successfully!');
});
it('reports errors when clearing swipes fails', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeResponse({ config: {} }))
.mockResolvedValueOnce(makeResponse({ error: 'Clear failed' }, false));
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
const result = renderHook(() => useBookDateSettings());
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
const onError = vi.fn();
await act(async () => {
await result.current.clearSwipes(vi.fn(), onError);
});
expect(onError).toHaveBeenCalledWith('Clear failed');
});
});
@@ -0,0 +1,130 @@
/**
* Component: Download Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const downloadClient = {
type: 'qbittorrent',
url: 'http://host',
username: 'user',
password: 'pass',
disableSSLVerify: false,
remotePathMappingEnabled: false,
remotePath: '',
localPath: '',
};
describe('useDownloadSettings', () => {
const onChange = vi.fn();
const onValidationChange = vi.fn();
beforeEach(() => {
fetchWithAuthMock.mockReset();
onChange.mockReset();
onValidationChange.mockReset();
});
it('updates fields and resets validation', async () => {
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
act(() => {
result.current.updateField('url', 'http://new');
});
expect(onChange).toHaveBeenCalledWith({ ...downloadClient, url: 'http://new' });
expect(onValidationChange).toHaveBeenCalledWith(false);
});
it('resets credentials when changing download client type', async () => {
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
act(() => {
result.current.handleTypeChange('sabnzbd');
});
expect(onChange).toHaveBeenCalledWith({
...downloadClient,
type: 'sabnzbd',
username: '',
password: '',
});
expect(onValidationChange).toHaveBeenCalledWith(false);
});
it('tests the download client connection successfully', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, version: '1.2.3' }),
});
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
await act(async () => {
const response = await result.current.testConnection();
expect(response?.success).toBe(true);
});
await waitFor(() => {
expect(result.current.testResult?.message).toContain('qbittorrent');
});
expect(onValidationChange).toHaveBeenCalledWith(true);
});
it('handles download client test failures', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: false, error: 'Bad credentials' }),
});
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
await act(async () => {
const response = await result.current.testConnection();
expect(response?.success).toBe(false);
});
expect(result.current.testResult?.message).toBe('Bad credentials');
expect(onValidationChange).toHaveBeenCalledWith(false);
});
it('handles download client test exceptions', async () => {
fetchWithAuthMock.mockRejectedValueOnce(new Error('network down'));
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
await act(async () => {
const response = await result.current.testConnection();
expect(response?.success).toBe(false);
});
expect(result.current.testResult?.message).toBe('network down');
expect(onValidationChange).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,149 @@
/**
* Component: Ebook Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const baseEbook = {
enabled: true,
preferredFormat: 'epub',
baseUrl: 'https://annas-archive.li',
flaresolverrUrl: 'http://flare',
};
describe('useEbookSettings', () => {
const onChange = vi.fn();
const onSuccess = vi.fn();
const onError = vi.fn();
const markAsSaved = vi.fn();
beforeEach(() => {
fetchWithAuthMock.mockReset();
onChange.mockReset();
onSuccess.mockReset();
onError.mockReset();
markAsSaved.mockReset();
vi.useRealTimers();
});
it('updates ebook settings and clears flaresolverr test results when URL changes', async () => {
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
act(() => {
result.current.updateEbook('flaresolverrUrl', 'http://new');
});
expect(onChange).toHaveBeenCalledWith({ ...baseEbook, flaresolverrUrl: 'http://new' });
expect(result.current.flaresolverrTestResult).toBeNull();
});
it('returns an error when testing FlareSolverr without a URL', async () => {
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: { ...baseEbook, flaresolverrUrl: '' }, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.testFlaresolverrConnection();
});
expect(result.current.flaresolverrTestResult?.success).toBe(false);
expect(result.current.flaresolverrTestResult?.message).toContain('Please enter a FlareSolverr URL');
});
it('tests FlareSolverr connection successfully', async () => {
fetchWithAuthMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, message: 'OK' }),
});
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.testFlaresolverrConnection();
});
expect(result.current.flaresolverrTestResult?.success).toBe(true);
});
it('handles FlareSolverr test failures', async () => {
fetchWithAuthMock.mockRejectedValueOnce(new Error('flare down'));
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.testFlaresolverrConnection();
});
expect(result.current.flaresolverrTestResult?.message).toBe('flare down');
});
it('saves ebook settings and clears success banner after delay', async () => {
vi.useFakeTimers();
fetchWithAuthMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.saveSettings();
});
expect(onSuccess).toHaveBeenCalledWith('E-book sidecar settings saved successfully!');
expect(markAsSaved).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(3000);
});
expect(onSuccess).toHaveBeenCalledWith('');
vi.useRealTimers();
});
it('surfaces save errors', async () => {
fetchWithAuthMock.mockResolvedValueOnce({ ok: false, json: async () => ({}) });
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
const result = renderHook(() =>
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
);
await act(async () => {
await result.current.saveSettings();
});
expect(onError).toHaveBeenCalledWith('Failed to save e-book settings');
});
});
@@ -0,0 +1,148 @@
/**
* Component: Library Settings Hook Tests
* Documentation: documentation/settings-pages.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const renderHook = <T,>(hook: () => T) => {
const result = { current: undefined as T };
function Probe() {
result.current = hook();
return null;
}
render(<Probe />);
return result;
};
const makeResponse = (body: any) => ({
ok: true,
json: async () => body,
});
describe('useLibrarySettings', () => {
const onSuccess = vi.fn();
const onError = vi.fn();
const onValidationChange = vi.fn();
beforeEach(() => {
fetchWithAuthMock.mockReset();
onSuccess.mockReset();
onError.mockReset();
onValidationChange.mockReset();
});
it('tests Plex connection successfully and stores libraries', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({
success: true,
serverName: 'Plex Server',
libraries: [{ id: 'lib-1', title: 'Main' }],
})
);
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testPlexConnection('http://plex', 'token');
expect(ok).toBe(true);
});
expect(result.current.plexLibraries).toHaveLength(1);
expect(result.current.plexTestResult?.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('Connected to Plex Server. You can now save.');
expect(onValidationChange).toHaveBeenCalledWith('plex', true);
});
it('surfaces Plex connection errors', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({
success: false,
error: 'Bad token',
})
);
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testPlexConnection('http://plex', 'token');
expect(ok).toBe(false);
});
expect(result.current.plexTestResult?.message).toBe('Bad token');
expect(onError).toHaveBeenCalledWith('Bad token');
expect(onValidationChange).toHaveBeenCalledWith('plex', false);
});
it('tests Audiobookshelf connection successfully and stores libraries', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({
success: true,
libraries: [{ id: 'abs-1', name: 'ABS Main' }],
})
);
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testABSConnection('http://abs', 'token');
expect(ok).toBe(true);
});
expect(result.current.absLibraries).toHaveLength(1);
expect(result.current.absTestResult?.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('Connected to Audiobookshelf. You can now save.');
expect(onValidationChange).toHaveBeenCalledWith('audiobookshelf', true);
});
it('surfaces Audiobookshelf connection failures', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeResponse({
success: false,
error: 'ABS down',
})
);
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testABSConnection('http://abs', 'token');
expect(ok).toBe(false);
});
expect(result.current.absTestResult?.message).toBe('ABS down');
expect(onError).toHaveBeenCalledWith('ABS down');
expect(onValidationChange).toHaveBeenCalledWith('audiobookshelf', false);
});
it('handles exceptions while testing connections', async () => {
fetchWithAuthMock.mockRejectedValueOnce(new Error('network down'));
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
await act(async () => {
const ok = await result.current.testPlexConnection('http://plex', 'token');
expect(ok).toBe(false);
});
await waitFor(() => {
expect(result.current.plexTestResult?.message).toBe('network down');
});
expect(onValidationChange).toHaveBeenCalledWith('plex', false);
});
});