Add series fields to audiobooks and update related logic

Introduces 'series' and 'seriesPart' fields to the Audiobook model and database schema. Updates API routes, file organization, and path template utilities to support series metadata. Enhances chapter merging logic, improves notification backend testing, and expands test coverage for admin and API routes.
This commit is contained in:
kikootwo
2026-01-22 15:56:55 -05:00
parent dc7e557694
commit 31bca0052f
105 changed files with 10384 additions and 75 deletions
@@ -0,0 +1,43 @@
/**
* Component: Setup Wizard Layout Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
describe('WizardLayout', () => {
it('renders Plex steps and footer progress', async () => {
const { WizardLayout } = await import('@/app/setup/components/WizardLayout');
render(
<WizardLayout currentStep={3} totalSteps={10} backendMode="plex">
<div>Content</div>
</WizardLayout>
);
expect(screen.getByText('ReadMeABook Setup')).toBeInTheDocument();
expect(screen.getByText('Plex')).toBeInTheDocument();
expect(screen.getByText('Finalize')).toBeInTheDocument();
expect(screen.getByText('Step 3 of 10')).toBeInTheDocument();
});
it('renders Audiobookshelf steps based on auth method', async () => {
const { WizardLayout } = await import('@/app/setup/components/WizardLayout');
render(
<WizardLayout currentStep={2} totalSteps={8} backendMode="audiobookshelf" authMethod="oidc">
<div>Content</div>
</WizardLayout>
);
expect(screen.getByText('ABS')).toBeInTheDocument();
expect(screen.getByText('Auth')).toBeInTheDocument();
expect(screen.getByText('OIDC')).toBeInTheDocument();
expect(screen.queryByText('Registration')).toBeNull();
expect(screen.queryByText('Admin')).toBeNull();
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* Component: Setup Initializing Page Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { resetMockRouter, routerMock } from '../../helpers/mock-next-navigation';
describe('InitializingPage', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
localStorage.clear();
window.location.hash = '';
resetMockRouter();
});
it('redirects to login when auth data is missing', async () => {
window.location.hash = '';
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
render(<InitializingPage />);
await waitFor(() => {
expect(routerMock.push).toHaveBeenCalledWith(
'/login?error=Authentication%20data%20missing'
);
});
});
it('processes auth data and completes job monitoring', async () => {
vi.useFakeTimers();
const authData = {
accessToken: 'token-123',
refreshToken: 'refresh-123',
user: { id: 'user-1', username: 'admin' },
};
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/admin/jobs') {
return {
ok: true,
json: async () => ({
jobs: [
{ id: 'job-1', type: 'audible_refresh', lastRunJobId: 'run-1' },
{ id: 'job-2', type: 'plex_library_scan', lastRunJobId: 'run-2' },
],
}),
};
}
if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') {
return { ok: true, json: async () => ({ job: { status: 'completed' } }) };
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
render(<InitializingPage />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(localStorage.getItem('accessToken')).toBe('token-123');
expect(window.location.hash).toBe('');
const completedMessages = screen.getAllByText('Completed successfully');
expect(completedMessages.length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
});
it('marks jobs as error when no recent job is found', async () => {
vi.useFakeTimers();
const authData = {
accessToken: 'token-123',
refreshToken: 'refresh-123',
user: { id: 'user-1', username: 'admin' },
};
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/admin/jobs') {
return {
ok: true,
json: async () => ({
jobs: [
{ id: 'job-1', type: 'audible_refresh' },
{ id: 'job-2', type: 'plex_library_scan' },
],
}),
};
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
render(<InitializingPage />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getAllByText(/Job did not start/).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
});
it('redirects when auth data fails to parse', async () => {
window.location.hash = '#authData=';
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
render(<InitializingPage />);
await waitFor(() => {
expect(routerMock.push).toHaveBeenCalledWith(
'/login?error=Failed%20to%20process%20authentication'
);
});
expect(errorMock).toHaveBeenCalledWith(
'[Initializing] Failed to process auth data:',
expect.any(Error)
);
});
it('marks jobs as error when scheduled jobs fetch fails', async () => {
vi.useFakeTimers();
const authData = {
accessToken: 'token-123',
refreshToken: 'refresh-123',
user: { id: 'user-1', username: 'admin' },
};
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/admin/jobs') {
return { ok: false, json: async () => ({}) };
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
render(<InitializingPage />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getAllByText(/Failed to fetch job configuration/).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
});
it('marks jobs as failed when job status returns failed', async () => {
vi.useFakeTimers();
const authData = {
accessToken: 'token-123',
refreshToken: 'refresh-123',
user: { id: 'user-1', username: 'admin' },
};
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/admin/jobs') {
return {
ok: true,
json: async () => ({
jobs: [
{ id: 'job-1', type: 'audible_refresh', lastRunJobId: 'run-1' },
{ id: 'job-2', type: 'plex_library_scan', lastRunJobId: 'run-2' },
],
}),
};
}
if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') {
return { ok: true, json: async () => ({ job: { status: 'failed' } }) };
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
render(<InitializingPage />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getAllByText(/Job failed to complete/).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
});
});
@@ -0,0 +1,86 @@
/**
* Component: Admin Account Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { AdminAccountStep } from '@/app/setup/steps/AdminAccountStep';
const AdminAccountHarness = ({
onNext,
onBack,
initialUsername = '',
initialPassword = '',
}: {
onNext: () => void;
onBack: () => void;
initialUsername?: string;
initialPassword?: string;
}) => {
const [adminUsername, setAdminUsername] = useState(initialUsername);
const [adminPassword, setAdminPassword] = useState(initialPassword);
return (
<AdminAccountStep
adminUsername={adminUsername}
adminPassword={adminPassword}
onUpdate={(field, value) => {
if (field === 'adminUsername') {
setAdminUsername(value);
}
if (field === 'adminPassword') {
setAdminPassword(value);
}
}}
onNext={onNext}
onBack={onBack}
/>
);
};
describe('AdminAccountStep', () => {
it('shows validation errors and blocks next when invalid', async () => {
const onNext = vi.fn();
const onBack = vi.fn();
render(
<AdminAccountStep
adminUsername="ad"
adminPassword="short"
onUpdate={vi.fn()}
onNext={onNext}
onBack={onBack}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Username must be at least 3 characters')).toBeInTheDocument();
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument();
expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
expect(onNext).not.toHaveBeenCalled();
});
it('allows navigation when credentials are valid', async () => {
const onNext = vi.fn();
const onBack = vi.fn();
render(
<AdminAccountHarness
onNext={onNext}
onBack={onBack}
initialUsername="admin"
initialPassword="supersecret"
/>
);
fireEvent.change(screen.getByLabelText('Confirm Password'), {
target: { value: 'supersecret' },
});
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalled();
});
});
@@ -0,0 +1,83 @@
/**
* Component: Setup Audiobookshelf Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AudiobookshelfStep } from '@/app/setup/steps/AudiobookshelfStep';
const AudiobookshelfHarness = ({
onNext,
onBack,
initialState,
}: {
onNext: () => void;
onBack: () => void;
initialState?: Partial<React.ComponentProps<typeof AudiobookshelfStep>>;
}) => {
const [state, setState] = useState({
absUrl: 'http://abs.local',
absApiToken: 'token',
absLibraryId: '',
absTriggerScanAfterImport: false,
...initialState,
});
return (
<AudiobookshelfStep
{...state}
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
onNext={onNext}
onBack={onBack}
/>
);
};
describe('AudiobookshelfStep', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('requires library selection after successful test', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
libraries: [{ id: 'lib-1', name: 'Main', itemCount: 10 }],
}),
});
vi.stubGlobal('fetch', fetchMock);
const onNext = vi.fn();
render(<AudiobookshelfHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-abs', expect.any(Object));
});
await screen.findByText('Connection successful!');
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Please select an audiobook library')).toBeInTheDocument();
expect(onNext).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
await screen.findByText('Connection successful!');
const librarySelect = await screen.findByRole('combobox');
fireEvent.change(librarySelect, { target: { value: 'lib-1' } });
await waitFor(() => {
expect(librarySelect).toHaveValue('lib-1');
});
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalled();
});
});
@@ -0,0 +1,104 @@
/**
* Component: Auth Method Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
describe('AuthMethodStep', () => {
it('highlights the selected auth method', async () => {
const { AuthMethodStep } = await import('@/app/setup/steps/AuthMethodStep');
const { rerender } = render(
<AuthMethodStep
value="oidc"
onChange={vi.fn()}
onNext={vi.fn()}
onBack={vi.fn()}
/>
);
const oidcLabel = screen.getByRole('radio', { name: /OIDC Provider/i }).closest('label');
const manualLabel = screen.getByRole('radio', { name: /Manual Registration/i }).closest('label');
const bothLabel = screen.getByRole('radio', { name: /Both/i }).closest('label');
expect(oidcLabel).toHaveClass('border-blue-500');
expect(manualLabel).toHaveClass('border-gray-200');
expect(bothLabel).toHaveClass('border-gray-200');
rerender(
<AuthMethodStep
value="manual"
onChange={vi.fn()}
onNext={vi.fn()}
onBack={vi.fn()}
/>
);
expect(screen.getByRole('radio', { name: /Manual Registration/i }).closest('label')).toHaveClass('border-blue-500');
rerender(
<AuthMethodStep
value="both"
onChange={vi.fn()}
onNext={vi.fn()}
onBack={vi.fn()}
/>
);
expect(screen.getByRole('radio', { name: /Both/i }).closest('label')).toHaveClass('border-blue-500');
});
it('updates auth method and navigates', async () => {
const onChange = vi.fn();
const onNext = vi.fn();
const onBack = vi.fn();
const { AuthMethodStep } = await import('@/app/setup/steps/AuthMethodStep');
const { rerender } = render(
<AuthMethodStep
value="oidc"
onChange={onChange}
onNext={onNext}
onBack={onBack}
/>
);
fireEvent.click(screen.getByRole('radio', { name: /Manual Registration/i }));
expect(onChange).toHaveBeenCalledWith('manual');
rerender(
<AuthMethodStep
value="manual"
onChange={onChange}
onNext={onNext}
onBack={onBack}
/>
);
fireEvent.click(screen.getByRole('radio', { name: /OIDC Provider/i }));
expect(onChange).toHaveBeenCalledWith('oidc');
rerender(
<AuthMethodStep
value="oidc"
onChange={onChange}
onNext={onNext}
onBack={onBack}
/>
);
fireEvent.click(screen.getByRole('radio', { name: /Both/i }));
expect(onChange).toHaveBeenCalledWith('both');
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onBack).toHaveBeenCalled();
expect(onNext).toHaveBeenCalled();
});
});
@@ -0,0 +1,73 @@
/**
* Component: Backend Selection Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
describe('BackendSelectionStep', () => {
it('updates the audible region helper based on backend', async () => {
const { BackendSelectionStep } = await import('@/app/setup/steps/BackendSelectionStep');
const { rerender } = render(
<BackendSelectionStep
value="plex"
onChange={vi.fn()}
audibleRegion="us"
onAudibleRegionChange={vi.fn()}
onNext={vi.fn()}
onBack={vi.fn()}
/>
);
expect(screen.getByText(/configuration in Plex/i)).toBeInTheDocument();
rerender(
<BackendSelectionStep
value="audiobookshelf"
onChange={vi.fn()}
audibleRegion="us"
onAudibleRegionChange={vi.fn()}
onNext={vi.fn()}
onBack={vi.fn()}
/>
);
expect(screen.getByText(/configuration in Audiobookshelf/i)).toBeInTheDocument();
});
it('updates backend selection and audible region', async () => {
const onChange = vi.fn();
const onAudibleRegionChange = vi.fn();
const onNext = vi.fn();
const onBack = vi.fn();
const { BackendSelectionStep } = await import('@/app/setup/steps/BackendSelectionStep');
render(
<BackendSelectionStep
value="plex"
onChange={onChange}
audibleRegion="us"
onAudibleRegionChange={onAudibleRegionChange}
onNext={onNext}
onBack={onBack}
/>
);
fireEvent.click(screen.getByRole('radio', { name: /Audiobookshelf/i }));
expect(onChange).toHaveBeenCalledWith('audiobookshelf');
fireEvent.change(screen.getByLabelText('Audible Region'), { target: { value: 'uk' } });
expect(onAudibleRegionChange).toHaveBeenCalledWith('uk');
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onBack).toHaveBeenCalled();
expect(onNext).toHaveBeenCalled();
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* Component: Setup BookDate Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BookDateStep } from '@/app/setup/steps/BookDateStep';
const BookDateHarness = ({
onNext,
onSkip,
onBack,
initialState,
}: {
onNext: () => void;
onSkip: () => void;
onBack: () => void;
initialState?: Partial<React.ComponentProps<typeof BookDateStep>>;
}) => {
const [state, setState] = useState({
bookdateProvider: 'openai',
bookdateApiKey: '',
bookdateModel: '',
bookdateConfigured: false,
...initialState,
});
return (
<BookDateStep
{...state}
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
onNext={onNext}
onSkip={onSkip}
onBack={onBack}
/>
);
};
describe('BookDateStep', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('disables connection test when API key is missing', async () => {
render(
<BookDateHarness onNext={vi.fn()} onSkip={vi.fn()} onBack={vi.fn()} />
);
expect(screen.getByRole('button', { name: /Test Connection/ })).toBeDisabled();
});
it('fetches models and proceeds after successful test', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
models: [
{ id: 'model-1', name: 'Model One' },
],
}),
});
vi.stubGlobal('fetch', fetchMock);
const onNext = vi.fn();
render(
<BookDateHarness
onNext={onNext}
onSkip={vi.fn()}
onBack={vi.fn()}
initialState={{ bookdateApiKey: 'key' }}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Test Connection/ }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/bookdate/test-connection', expect.any(Object));
});
expect(await screen.findByText('Select Model')).toBeInTheDocument();
const nextButton = screen.getByRole('button', { name: 'Next' });
await waitFor(() => {
expect(nextButton).not.toBeDisabled();
});
fireEvent.click(nextButton);
expect(onNext).toHaveBeenCalled();
});
it('shows an error when the connection test fails', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: 'Invalid API key' }),
});
vi.stubGlobal('fetch', fetchMock);
render(
<BookDateHarness
onNext={vi.fn()}
onSkip={vi.fn()}
onBack={vi.fn()}
initialState={{ bookdateApiKey: 'bad-key' }}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Test Connection/ }));
await waitFor(() => {
expect(screen.getByText('Invalid API key')).toBeInTheDocument();
});
});
it('auto-selects the first model and shows the note', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
models: [
{ id: 'model-1', name: 'Model One' },
{ id: 'model-2', name: 'Model Two' },
],
}),
});
vi.stubGlobal('fetch', fetchMock);
render(
<BookDateHarness
onNext={vi.fn()}
onSkip={vi.fn()}
onBack={vi.fn()}
initialState={{ bookdateApiKey: 'key' }}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Test Connection/ }));
await waitFor(() => {
expect(screen.getByText('Select Model')).toBeInTheDocument();
});
const selects = screen.getAllByRole('combobox');
const modelSelect = selects[1];
expect(modelSelect).toHaveValue('model-1');
expect(screen.getByText(/Library scope and custom prompt preferences/)).toBeInTheDocument();
});
it('clears tested state and models when switching providers', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
models: [{ id: 'model-1', name: 'Model One' }],
}),
});
vi.stubGlobal('fetch', fetchMock);
render(
<BookDateHarness
onNext={vi.fn()}
onSkip={vi.fn()}
onBack={vi.fn()}
initialState={{ bookdateApiKey: 'key' }}
/>
);
fireEvent.click(screen.getByRole('button', { name: /Test Connection/ }));
await waitFor(() => {
expect(screen.getByText('Select Model')).toBeInTheDocument();
});
const providerSelect = screen.getAllByRole('combobox')[0];
fireEvent.change(providerSelect, { target: { value: 'claude' } });
expect(screen.queryByText('Select Model')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
});
});
@@ -0,0 +1,156 @@
/**
* Component: Setup Download Client Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep';
const DownloadClientHarness = ({
onNext,
onBack,
initialState,
}: {
onNext: () => void;
onBack: () => void;
initialState?: Partial<React.ComponentProps<typeof DownloadClientStep>>;
}) => {
const [state, setState] = useState({
downloadClient: 'qbittorrent' as const,
downloadClientUrl: 'https://qbittorrent.local',
downloadClientUsername: 'admin',
downloadClientPassword: 'secret',
disableSSLVerify: false,
remotePathMappingEnabled: false,
remotePath: '',
localPath: '',
...initialState,
});
return (
<DownloadClientStep
{...state}
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
onNext={onNext}
onBack={onBack}
/>
);
};
describe('DownloadClientStep', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('tests connection and enables navigation after success', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, version: '1.2.3' }),
});
vi.stubGlobal('fetch', fetchMock);
const onNext = vi.fn();
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
});
expect(screen.getByText(/Connected successfully!/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalled();
});
it('shows remote path fields and toggles SSL verify', async () => {
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
const sslToggle = screen.getByLabelText('Disable SSL Certificate Verification');
fireEvent.click(sslToggle);
expect(sslToggle).toBeChecked();
const remoteToggle = screen.getByLabelText('Enable Remote Path Mapping');
fireEvent.click(remoteToggle);
expect(screen.getByPlaceholderText('/remote/mnt/d/done')).toBeInTheDocument();
expect(screen.getByPlaceholderText('/downloads')).toBeInTheDocument();
});
it('switches to SABnzbd and shows API key field', async () => {
render(
<DownloadClientHarness
onNext={vi.fn()}
onBack={vi.fn()}
initialState={{ downloadClient: 'qbittorrent' }}
/>
);
fireEvent.click(screen.getByRole('button', { name: /SABnzbd/ }));
expect(screen.getByText('API Key')).toBeInTheDocument();
expect(screen.queryByText('Username')).toBeNull();
});
it('blocks next when connection has not been tested', async () => {
const onNext = vi.fn();
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Please test the connection before proceeding')).toBeInTheDocument();
expect(onNext).not.toHaveBeenCalled();
});
it('shows an error when the connection test fails', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: 'Bad credentials' }),
});
vi.stubGlobal('fetch', fetchMock);
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
});
expect(screen.getByText('Bad credentials')).toBeInTheDocument();
});
it('disables test connection when SABnzbd fields are incomplete', async () => {
render(
<DownloadClientHarness
onNext={vi.fn()}
onBack={vi.fn()}
initialState={{
downloadClient: 'sabnzbd',
downloadClientUrl: '',
downloadClientPassword: '',
}}
/>
);
const testButton = screen.getByRole('button', { name: 'Test Connection' });
expect(testButton).toBeDisabled();
});
it('hides SSL toggle when using http URLs', async () => {
render(
<DownloadClientHarness
onNext={vi.fn()}
onBack={vi.fn()}
initialState={{ downloadClientUrl: 'http://qbittorrent.local' }}
/>
);
expect(screen.queryByLabelText('Disable SSL Certificate Verification')).toBeNull();
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* Component: Finalize Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('FinalizeStep', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
localStorage.clear();
});
it('shows OIDC-only instructions and completes setup', async () => {
const onComplete = vi.fn();
const onBack = vi.fn();
const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep');
render(
<FinalizeStep hasAdminTokens={false} onComplete={onComplete} onBack={onBack} />
);
expect(screen.getByText('Setup Complete!')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
fireEvent.click(screen.getByRole('button', { name: 'Finish Setup' }));
expect(onBack).toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
it('marks jobs as error when no access token is available', async () => {
const onComplete = vi.fn();
const onBack = vi.fn();
const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep');
render(
<FinalizeStep hasAdminTokens={true} onComplete={onComplete} onBack={onBack} />
);
await waitFor(() => {
expect(screen.getAllByText(/Authentication required/).length).toBeGreaterThan(0);
});
});
it('runs initial jobs and enables completion on success', async () => {
vi.useFakeTimers();
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/admin/jobs') {
return {
ok: true,
json: async () => ({
jobs: [
{ id: 'job-1', type: 'audible_refresh' },
{ id: 'job-2', type: 'plex_library_scan' },
],
}),
};
}
if (url === '/api/admin/jobs/job-1/trigger') {
return { ok: true, json: async () => ({ jobId: 'run-1' }) };
}
if (url === '/api/admin/jobs/job-2/trigger') {
return { ok: true, json: async () => ({ jobId: 'run-2' }) };
}
if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') {
return { ok: true, json: async () => ({ job: { status: 'completed' } }) };
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const onComplete = vi.fn();
const onBack = vi.fn();
const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep');
render(
<FinalizeStep hasAdminTokens={true} onComplete={onComplete} onBack={onBack} />
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getAllByText('Completed successfully').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Finish Setup' })).toBeEnabled();
});
it('marks missing job configuration as an error', async () => {
vi.useFakeTimers();
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/admin/jobs') {
return {
ok: true,
json: async () => ({
jobs: [
{ id: 'job-1', type: 'audible_refresh' },
],
}),
};
}
if (url === '/api/admin/jobs/job-1/trigger') {
return { ok: true, json: async () => ({ jobId: 'run-1' }) };
}
if (url === '/api/admin/job-status/run-1') {
return { ok: true, json: async () => ({ job: { status: 'completed' } }) };
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep');
render(
<FinalizeStep hasAdminTokens={true} onComplete={vi.fn()} onBack={vi.fn()} />
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getAllByText(/Job configuration not found/).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Finish Setup' })).toBeEnabled();
});
});
@@ -0,0 +1,290 @@
/**
* Component: Setup OIDC Config Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OIDCConfigStep } from '@/app/setup/steps/OIDCConfigStep';
const OIDCHarness = ({
onNext,
onBack,
initialState,
}: {
onNext: () => void;
onBack: () => void;
initialState?: Partial<React.ComponentProps<typeof OIDCConfigStep>>;
}) => {
const [state, setState] = useState({
oidcProviderName: 'Auth',
oidcIssuerUrl: 'https://auth.example.com',
oidcClientId: 'client',
oidcClientSecret: 'secret',
oidcAccessControlMethod: 'open',
oidcAccessGroupClaim: '',
oidcAccessGroupValue: '',
oidcAllowedEmails: '',
oidcAllowedUsernames: '',
oidcAdminClaimEnabled: false,
oidcAdminClaimName: '',
oidcAdminClaimValue: '',
...initialState,
});
return (
<OIDCConfigStep
{...state}
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
onNext={onNext}
onBack={onBack}
/>
);
};
describe('OIDCConfigStep', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('requires a successful test before proceeding', async () => {
const onNext = vi.fn();
render(<OIDCHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Please test the OIDC configuration before proceeding')).toBeInTheDocument();
expect(onNext).not.toHaveBeenCalled();
});
it('tests connection and shows access control fields', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
vi.stubGlobal('fetch', fetchMock);
const onNext = vi.fn();
render(<OIDCHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-oidc', expect.any(Object));
});
expect(screen.getByText('OIDC discovery successful! Provider configuration validated.')).toBeInTheDocument();
const accessControlSelect = screen.getByRole('combobox');
fireEvent.change(accessControlSelect, {
target: { value: 'allowed_list' },
});
expect(screen.getByPlaceholderText('user1@example.com, user2@example.com')).toBeInTheDocument();
expect(screen.getByPlaceholderText('john_doe, jane_smith')).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('Enable Admin Role Mapping'));
expect(screen.getByPlaceholderText('groups')).toBeInTheDocument();
expect(screen.getByPlaceholderText('readmeabook-admin')).toBeInTheDocument();
});
it('disables testing when required fields are missing', () => {
render(
<OIDCHarness
onNext={vi.fn()}
onBack={vi.fn()}
initialState={{ oidcIssuerUrl: '' }}
/>,
);
expect(screen.getByRole('button', { name: 'Test OIDC Configuration' })).toBeDisabled();
});
it('shows error text when connection test fails', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: false, error: 'Invalid issuer' }),
});
vi.stubGlobal('fetch', fetchMock);
render(<OIDCHarness onNext={vi.fn()} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' }));
await waitFor(() => {
expect(screen.getByText('Invalid issuer')).toBeInTheDocument();
});
});
it('shows error text when connection test throws', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('Network down'));
vi.stubGlobal('fetch', fetchMock);
render(<OIDCHarness onNext={vi.fn()} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' }));
await waitFor(() => {
expect(screen.getByText('Network down')).toBeInTheDocument();
});
});
it('updates access control helper text and fields per method', () => {
render(<OIDCHarness onNext={vi.fn()} onBack={vi.fn()} />);
const accessControlSelect = screen.getByRole('combobox');
expect(
screen.getByText('Anyone who can authenticate with your OIDC provider will have access'),
).toBeInTheDocument();
fireEvent.change(accessControlSelect, {
target: { value: 'group_claim' },
});
expect(screen.getByText('Only users with a specific group/claim can access')).toBeInTheDocument();
expect(screen.getByPlaceholderText('readmeabook-users')).toBeInTheDocument();
fireEvent.change(accessControlSelect, {
target: { value: 'allowed_list' },
});
expect(screen.getByText('Only explicitly allowed users can access')).toBeInTheDocument();
fireEvent.change(accessControlSelect, {
target: { value: 'admin_approval' },
});
expect(
screen.getByText('New users must be approved by an admin before access is granted'),
).toBeInTheDocument();
});
it('allows proceeding after a successful test', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
vi.stubGlobal('fetch', fetchMock);
const onNext = vi.fn();
render(<OIDCHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' }));
await waitFor(() => {
expect(screen.getByText('OIDC discovery successful! Provider configuration validated.')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalled();
});
it('updates provider fields and access control inputs', () => {
const onUpdate = vi.fn();
render(
<OIDCConfigStep
oidcProviderName="Auth"
oidcIssuerUrl="https://auth.example.com"
oidcClientId="client"
oidcClientSecret="secret"
oidcAccessControlMethod="group_claim"
oidcAccessGroupClaim="groups"
oidcAccessGroupValue="readmeabook-users"
oidcAllowedEmails=""
oidcAllowedUsernames=""
oidcAdminClaimEnabled={true}
oidcAdminClaimName="groups"
oidcAdminClaimValue="readmeabook-admin"
onUpdate={onUpdate}
onNext={vi.fn()}
onBack={vi.fn()}
/>,
);
fireEvent.change(screen.getByPlaceholderText('Authentik'), {
target: { value: 'Keycloak' },
});
fireEvent.change(
screen.getByPlaceholderText('https://auth.example.com/application/o/readmeabook/'),
{
target: { value: 'https://issuer.example' },
},
);
fireEvent.change(screen.getByPlaceholderText('readmeabook'), {
target: { value: 'rmab-client' },
});
fireEvent.change(screen.getByPlaceholderText('Enter client secret'), {
target: { value: 'new-secret' },
});
const groupInputs = screen.getAllByPlaceholderText('groups');
fireEvent.change(groupInputs[0], { target: { value: 'roles' } });
fireEvent.change(screen.getByPlaceholderText('readmeabook-users'), {
target: { value: 'rmab-users' },
});
fireEvent.change(groupInputs[1], { target: { value: 'admin-roles' } });
fireEvent.change(screen.getByPlaceholderText('readmeabook-admin'), {
target: { value: 'rmab-admin' },
});
expect(onUpdate).toHaveBeenCalledWith('oidcProviderName', 'Keycloak');
expect(onUpdate).toHaveBeenCalledWith('oidcIssuerUrl', 'https://issuer.example');
expect(onUpdate).toHaveBeenCalledWith('oidcClientId', 'rmab-client');
expect(onUpdate).toHaveBeenCalledWith('oidcClientSecret', 'new-secret');
expect(onUpdate).toHaveBeenCalledWith('oidcAccessGroupClaim', 'roles');
expect(onUpdate).toHaveBeenCalledWith('oidcAccessGroupValue', 'rmab-users');
expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimName', 'admin-roles');
expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimValue', 'rmab-admin');
});
it('updates allowed list fields and toggles admin mapping', () => {
const onUpdate = vi.fn();
render(
<OIDCConfigStep
oidcProviderName="Auth"
oidcIssuerUrl="https://auth.example.com"
oidcClientId="client"
oidcClientSecret="secret"
oidcAccessControlMethod="allowed_list"
oidcAccessGroupClaim=""
oidcAccessGroupValue=""
oidcAllowedEmails=""
oidcAllowedUsernames=""
oidcAdminClaimEnabled={false}
oidcAdminClaimName=""
oidcAdminClaimValue=""
onUpdate={onUpdate}
onNext={vi.fn()}
onBack={vi.fn()}
/>,
);
fireEvent.change(screen.getByRole('combobox'), {
target: { value: 'allowed_list' },
});
fireEvent.change(screen.getByPlaceholderText('user1@example.com, user2@example.com'), {
target: { value: 'reader@example.com' },
});
fireEvent.change(screen.getByPlaceholderText('john_doe, jane_smith'), {
target: { value: 'reader1' },
});
fireEvent.click(screen.getByLabelText('Enable Admin Role Mapping'));
expect(onUpdate).toHaveBeenCalledWith('oidcAccessControlMethod', 'allowed_list');
expect(onUpdate).toHaveBeenCalledWith('oidcAllowedEmails', 'reader@example.com');
expect(onUpdate).toHaveBeenCalledWith('oidcAllowedUsernames', 'reader1');
expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimEnabled', true);
});
it('navigates back when Back is clicked', () => {
const onBack = vi.fn();
render(<OIDCHarness onNext={vi.fn()} onBack={onBack} />);
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(onBack).toHaveBeenCalled();
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Component: Setup Paths Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PathsStep } from '@/app/setup/steps/PathsStep';
const PathsHarness = ({
onNext,
onBack,
initialState,
}: {
onNext: () => void;
onBack: () => void;
initialState?: Partial<React.ComponentProps<typeof PathsStep>>;
}) => {
const [state, setState] = useState({
downloadDir: '/downloads',
mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
...initialState,
});
return (
<PathsStep
{...state}
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
onNext={onNext}
onBack={onBack}
/>
);
};
describe('PathsStep', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('validates paths and allows navigation on success', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
message: 'Directories are ready',
downloadDirValid: true,
mediaDirValid: true,
}),
});
vi.stubGlobal('fetch', fetchMock);
const onNext = vi.fn();
render(<PathsHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Validate Paths' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-paths', expect.any(Object));
});
expect(screen.getByText('Directories are ready')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalled();
});
it('requires validation before proceeding', async () => {
const onNext = vi.fn();
render(<PathsHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Please validate the paths before proceeding')).toBeInTheDocument();
expect(onNext).not.toHaveBeenCalled();
});
it('toggles metadata and chapter merge settings', async () => {
render(
<PathsHarness
onNext={vi.fn()}
onBack={vi.fn()}
initialState={{ metadataTaggingEnabled: false, chapterMergingEnabled: false }}
/>
);
const metadataToggle = screen.getByLabelText('Auto-tag audio files with metadata');
const chapterToggle = screen.getByLabelText('Auto-merge chapters to M4B');
fireEvent.click(metadataToggle);
fireEvent.click(chapterToggle);
expect(metadataToggle).toBeChecked();
expect(chapterToggle).toBeChecked();
});
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Component: Setup Plex Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PlexStep } from '@/app/setup/steps/PlexStep';
const PlexHarness = ({
onNext,
onBack,
initialState,
}: {
onNext: () => void;
onBack: () => void;
initialState?: Partial<React.ComponentProps<typeof PlexStep>>;
}) => {
const [state, setState] = useState({
plexUrl: 'http://plex.local',
plexToken: 'token',
plexLibraryId: '',
plexTriggerScanAfterImport: false,
...initialState,
});
return (
<PlexStep
{...state}
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
onNext={onNext}
onBack={onBack}
/>
);
};
describe('PlexStep', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('requires library selection after successful test', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
serverName: 'Plex',
libraries: [{ id: 'lib-1', title: 'Audiobooks', type: 'artist' }],
}),
});
vi.stubGlobal('fetch', fetchMock);
const onNext = vi.fn();
render(<PlexHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-plex', expect.any(Object));
});
await screen.findByText(/Connected to Plex/i);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Please select an audiobook library')).toBeInTheDocument();
expect(onNext).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
await screen.findByText(/Connected to Plex/i);
const librarySelect = await screen.findByRole('combobox');
fireEvent.change(librarySelect, { target: { value: 'lib-1' } });
await waitFor(() => {
expect(librarySelect).toHaveValue('lib-1');
});
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
/**
* Component: Setup Prowlarr Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const indexersMock = [
{
id: 1,
name: 'Indexer',
priority: 10,
seedingTimeMinutes: 0,
rssEnabled: true,
categories: [],
},
];
vi.mock('@/components/admin/indexers/IndexerManagement', () => ({
IndexerManagement: ({ onIndexersChange }: { onIndexersChange: (indexers: any[]) => void }) => (
<button type="button" onClick={() => onIndexersChange(indexersMock)}>
Set Indexers
</button>
),
}));
describe('ProwlarrStep', () => {
it('shows validation errors when required fields are missing', async () => {
const { ProwlarrStep } = await import('@/app/setup/steps/ProwlarrStep');
const onNext = vi.fn();
render(
<ProwlarrStep
prowlarrUrl=""
prowlarrApiKey=""
onUpdate={vi.fn()}
onNext={onNext}
onBack={vi.fn()}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Please enter Prowlarr URL and API key')).toBeInTheDocument();
expect(onNext).not.toHaveBeenCalled();
});
it('updates indexers and proceeds when valid', async () => {
const { ProwlarrStep } = await import('@/app/setup/steps/ProwlarrStep');
const onUpdate = vi.fn();
const onNext = vi.fn();
render(
<ProwlarrStep
prowlarrUrl="http://localhost:9696"
prowlarrApiKey="key"
onUpdate={onUpdate}
onNext={onNext}
onBack={vi.fn()}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Set Indexers' }));
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith('prowlarrIndexers', indexersMock);
});
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
/**
* Component: Registration Settings Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RegistrationSettingsStep } from '@/app/setup/steps/RegistrationSettingsStep';
const RegistrationHarness = ({
onNext,
onBack,
initialValue = false,
}: {
onNext: () => void;
onBack: () => void;
initialValue?: boolean;
}) => {
const [requireAdminApproval, setRequireAdminApproval] = useState(initialValue);
return (
<RegistrationSettingsStep
requireAdminApproval={requireAdminApproval}
onUpdate={(_, value) => setRequireAdminApproval(value)}
onNext={onNext}
onBack={onBack}
/>
);
};
describe('RegistrationSettingsStep', () => {
it('toggles admin approval and navigates', async () => {
const onNext = vi.fn();
const onBack = vi.fn();
render(<RegistrationHarness onNext={onNext} onBack={onBack} />);
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onBack).toHaveBeenCalled();
expect(onNext).toHaveBeenCalled();
expect(screen.getByText('Auto-Approval Enabled')).toBeInTheDocument();
const buttons = screen.getAllByRole('button');
const toggleButton = buttons.find((button) => !['Back', 'Next'].includes(button.textContent || ''));
expect(toggleButton).toBeDefined();
fireEvent.click(toggleButton as HTMLButtonElement);
expect(screen.getByText('Admin Approval Workflow')).toBeInTheDocument();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Component: Setup Review Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ReviewStep } from '@/app/setup/steps/ReviewStep';
const baseConfig = {
backendMode: 'plex' as const,
plexUrl: 'http://plex.local',
plexLibraryId: 'plex-lib',
absUrl: 'http://abs.local',
absLibraryId: 'abs-lib',
authMethod: 'oidc' as const,
oidcProviderName: 'Auth',
adminUsername: 'admin',
prowlarrUrl: 'http://prowlarr.local',
downloadClient: 'qbittorrent' as const,
downloadClientUrl: 'http://qb.local',
downloadDir: '/downloads',
mediaDir: '/media',
bookdateConfigured: true,
bookdateProvider: 'openai',
bookdateModel: 'model-1',
};
describe('ReviewStep', () => {
it('renders Plex configuration and triggers actions', async () => {
const onComplete = vi.fn();
const onBack = vi.fn();
render(
<ReviewStep
config={baseConfig}
loading={false}
error={null}
onComplete={onComplete}
onBack={onBack}
/>
);
expect(screen.getByText('Plex Media Server')).toBeInTheDocument();
expect(screen.getByText('BookDate AI Recommendations')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
fireEvent.click(screen.getByRole('button', { name: 'Complete Setup' }));
expect(onBack).toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
it('renders Audiobookshelf config and error state', async () => {
render(
<ReviewStep
config={{ ...baseConfig, backendMode: 'audiobookshelf', authMethod: 'both' }}
loading={false}
error="Something went wrong"
onComplete={vi.fn()}
onBack={vi.fn()}
/>
);
expect(screen.getByText('Audiobookshelf')).toBeInTheDocument();
expect(screen.getByText('OIDC + Manual Registration')).toBeInTheDocument();
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
});
@@ -0,0 +1,22 @@
/**
* Component: Setup Welcome Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
describe('WelcomeStep', () => {
it('calls onNext when Get Started is clicked', async () => {
const onNext = vi.fn();
const { WelcomeStep } = await import('@/app/setup/steps/WelcomeStep');
render(<WelcomeStep onNext={onNext} />);
fireEvent.click(screen.getByRole('button', { name: /Get Started/i }));
expect(onNext).toHaveBeenCalled();
});
});