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
+164
View File
@@ -0,0 +1,164 @@
/**
* Component: Admin Settings Page Tests
* Documentation: documentation/settings-pages.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 saveTabSettingsMock = vi.hoisted(() => vi.fn());
const getTabValidationMock = vi.hoisted(() => vi.fn());
const getTabsMock = vi.hoisted(() => vi.fn());
const parseArrayToCommaSeparatedMock = vi.hoisted(() => vi.fn((value: string) => value));
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
}));
const mockAdminSettingsModules = () => {
vi.doMock(path.resolve('src/app/admin/settings/lib/helpers.ts'), () => ({
parseArrayToCommaSeparated: parseArrayToCommaSeparatedMock,
saveTabSettings: saveTabSettingsMock,
validateAuthSettings: vi.fn(() => ({ valid: true })),
getTabValidation: getTabValidationMock,
getTabs: getTabsMock,
}));
vi.doMock(path.resolve('src/app/admin/settings/tabs/LibraryTab/LibraryTab.tsx'), () => ({
LibraryTab: ({ settings, onChange }: { settings: any; onChange: (next: any) => void }) => (
<div>
<div>Library Tab</div>
<button type="button" onClick={() => onChange({ ...settings, audibleRegion: 'uk' })}>
Change Settings
</button>
</div>
),
}));
vi.doMock(path.resolve('src/app/admin/settings/tabs/AuthTab/AuthTab.tsx'), () => ({
AuthTab: () => <div>Auth Tab</div>,
}));
vi.doMock(path.resolve('src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx'), () => ({
IndexersTab: () => <div>Indexers Tab</div>,
}));
vi.doMock(path.resolve('src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx'), () => ({
DownloadTab: () => <div>Download Tab</div>,
}));
vi.doMock(path.resolve('src/app/admin/settings/tabs/PathsTab/PathsTab.tsx'), () => ({
PathsTab: () => <div>Paths Tab</div>,
}));
vi.doMock(path.resolve('src/app/admin/settings/tabs/EbookTab/EbookTab.tsx'), () => ({
EbookTab: () => <div>Ebook Tab</div>,
}));
vi.doMock(path.resolve('src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx'), () => ({
BookDateTab: () => <div>BookDate Tab</div>,
}));
vi.doMock(path.resolve('src/app/admin/settings/tabs/NotificationsTab/index.tsx'), () => ({
NotificationsTab: () => <div>Notifications Tab</div>,
}));
};
const settingsFixture = {
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: '[]',
allowedUsernames: '[]',
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('AdminSettings', () => {
beforeEach(() => {
fetchWithAuthMock.mockReset();
saveTabSettingsMock.mockReset();
getTabValidationMock.mockReset();
getTabsMock.mockReset();
parseArrayToCommaSeparatedMock.mockReset();
vi.resetModules();
mockAdminSettingsModules();
});
it('fetches settings and renders the settings shell', async () => {
fetchWithAuthMock.mockResolvedValue({
ok: true,
json: async () => settingsFixture,
});
getTabValidationMock.mockReturnValue(true);
getTabsMock.mockReturnValue([{ id: 'library', label: 'Library', icon: 'L' }]);
const { default: AdminSettings } = await import('@/app/admin/settings/page');
render(<AdminSettings />);
expect(await screen.findByText('Settings')).toBeInTheDocument();
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/settings');
});
it('saves settings when changes are made and validation passes', async () => {
fetchWithAuthMock.mockResolvedValue({
ok: true,
json: async () => settingsFixture,
});
getTabValidationMock.mockReturnValue(true);
getTabsMock.mockReturnValue([{ id: 'library', label: 'Library', icon: 'L' }]);
const { default: AdminSettings } = await import('@/app/admin/settings/page');
render(<AdminSettings />);
fireEvent.click(await screen.findByRole('button', { name: 'Change Settings' }));
fireEvent.click(await screen.findByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(saveTabSettingsMock).toHaveBeenCalledWith(
'library',
expect.objectContaining({ audibleRegion: 'uk' }),
[],
[]
);
});
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Component: Admin Users Page Tests
* Documentation: documentation/admin-dashboard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AdminUsersPage from '@/app/admin/users/page';
const fetchJSONMock = vi.hoisted(() => vi.fn());
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
const swrState = new Map<string, { data?: any; error?: any; mutate: ReturnType<typeof vi.fn> }>();
vi.mock('swr', () => ({
default: (key: string) => {
return swrState.get(key) || { data: undefined, error: undefined, mutate: vi.fn() };
},
}));
vi.mock('@/lib/utils/api', () => ({
authenticatedFetcher: authenticatedFetcherMock,
fetchJSON: fetchJSONMock,
}));
vi.mock('@/components/ui/Toast', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useToast: () => toastMock,
}));
describe('AdminUsersPage', () => {
beforeEach(() => {
swrState.clear();
fetchJSONMock.mockReset();
toastMock.success.mockReset();
toastMock.error.mockReset();
});
it('toggles global auto-approve and persists setting', async () => {
const mutateUsers = vi.fn();
const mutatePending = vi.fn();
const mutateGlobal = vi.fn();
swrState.set('/api/admin/users', {
data: { users: [{ id: 'u1', plexUsername: 'User', plexId: 'plex-1', role: 'user', isSetupAdmin: false, authProvider: 'local', plexEmail: null, avatarUrl: null, createdAt: '', updatedAt: '', lastLoginAt: null, autoApproveRequests: false, _count: { requests: 0 } }] },
mutate: mutateUsers,
});
swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: mutatePending });
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: false }, mutate: mutateGlobal });
fetchJSONMock.mockResolvedValueOnce({ success: true });
render(<AdminUsersPage />);
fireEvent.click(await screen.findByText('Auto-Approve All Requests'));
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/settings/auto-approve', {
method: 'PATCH',
body: JSON.stringify({ autoApproveRequests: true }),
});
expect(mutateGlobal).toHaveBeenCalled();
expect(mutateUsers).toHaveBeenCalled();
});
});
it('edits a user role and saves changes', async () => {
const mutateUsers = vi.fn();
swrState.set('/api/admin/users', {
data: {
users: [
{
id: 'u2',
plexUsername: 'LocalUser',
plexId: 'local-1',
role: 'user',
isSetupAdmin: false,
authProvider: 'local',
plexEmail: 'local@example.com',
avatarUrl: null,
createdAt: '',
updatedAt: '',
lastLoginAt: null,
autoApproveRequests: false,
_count: { requests: 2 },
},
],
},
mutate: mutateUsers,
});
swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: vi.fn() });
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() });
fetchJSONMock.mockResolvedValueOnce({ success: true });
render(<AdminUsersPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Edit Role' }));
fireEvent.click(screen.getByRole('radio', { name: /Admin/i }));
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u2', {
method: 'PUT',
body: JSON.stringify({ role: 'admin' }),
});
expect(mutateUsers).toHaveBeenCalled();
});
});
it('approves a pending user and refreshes lists', async () => {
const mutateUsers = vi.fn();
const mutatePending = vi.fn();
swrState.set('/api/admin/users', { data: { users: [] }, mutate: mutateUsers });
swrState.set('/api/admin/users/pending', {
data: {
users: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }],
},
mutate: mutatePending,
});
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() });
fetchJSONMock.mockResolvedValueOnce({ success: true });
render(<AdminUsersPage />);
const approveButtons = await screen.findAllByRole('button', { name: 'Approve' });
fireEvent.click(approveButtons[0]);
const confirmButtons = await screen.findAllByRole('button', { name: 'Approve' });
fireEvent.click(confirmButtons[1]);
await waitFor(() => {
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/p1/approve', {
method: 'POST',
body: JSON.stringify({ approve: true }),
});
expect(mutatePending).toHaveBeenCalled();
});
});
});
+294
View File
@@ -0,0 +1,294 @@
/**
* Component: BookDate Page Tests
* Documentation: documentation/features/bookdate.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockRouter, routerMock } from '../helpers/mock-next-navigation';
vi.mock('@/components/layout/Header', () => ({
Header: () => <div data-testid="header" />,
}));
vi.mock('@/components/bookdate/LoadingScreen', () => ({
LoadingScreen: () => <div data-testid="loading" />,
}));
vi.mock('@/components/bookdate/SettingsWidget', () => ({
SettingsWidget: ({
isOpen,
isOnboarding,
onOnboardingComplete,
}: {
isOpen: boolean;
isOnboarding: boolean;
onOnboardingComplete: () => void;
}) => (
<div data-testid="settings-widget" data-open={String(isOpen)} data-onboarding={String(isOnboarding)}>
<button type="button" onClick={onOnboardingComplete}>
Finish Onboarding
</button>
</div>
),
}));
vi.mock('@/components/bookdate/CardStack', () => ({
CardStack: ({
recommendations,
onSwipe,
onSwipeComplete,
}: {
recommendations: any[];
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
onSwipeComplete: () => void;
}) => (
<div>
<div data-testid="card-count">{recommendations.length}</div>
<button
type="button"
onClick={() => {
onSwipe('left');
onSwipeComplete();
}}
>
Swipe Left
</button>
<button
type="button"
onClick={() => {
onSwipe('right');
onSwipeComplete();
}}
>
Swipe Right
</button>
</div>
),
}));
const makeJsonResponse = (body: any, ok: boolean = true) => ({
ok,
status: ok ? 200 : 500,
json: async () => body,
});
describe('BookDatePage', () => {
beforeEach(() => {
resetMockRouter();
localStorage.clear();
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('redirects to login when no access token is available', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
await waitFor(() => {
expect(routerMock.push).toHaveBeenCalledWith('/login');
});
});
it('shows onboarding settings when onboarding is incomplete', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: false });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
expect(await screen.findByText('Welcome to BookDate!')).toBeInTheDocument();
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true');
});
it('renders an error state when recommendations fetch fails', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ error: 'bad' }, false);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
expect(await screen.findByText(/Could not load recommendations/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
await waitFor(() => {
const recCalls = fetchMock.mock.calls.filter(([input]) => String(input).includes('/api/bookdate/recommendations'));
expect(recCalls.length).toBeGreaterThan(1);
});
});
it('shows empty state and triggers recommendation generation', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [] });
}
if (url === '/api/bookdate/generate') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
const generateButton = await screen.findByRole('button', { name: 'Get More Recommendations' });
fireEvent.click(generateButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/bookdate/generate',
expect.objectContaining({ method: 'POST' })
);
});
});
it('posts swipes and shows undo option', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }, { id: 'rec-2' }] });
}
if (url === '/api/bookdate/swipe') {
return makeJsonResponse({ success: true });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
fireEvent.click(await screen.findByRole('button', { name: 'Swipe Left' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/bookdate/swipe',
expect.objectContaining({ method: 'POST' })
);
});
expect(await screen.findByRole('button', { name: /Undo/i })).toBeInTheDocument();
});
it('opens settings when the settings button is clicked', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
expect(await screen.findByTestId('card-count')).toHaveTextContent('1');
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'false');
fireEvent.click(screen.getByRole('button', { name: 'Open settings' }));
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true');
});
it('undoes a swipe and reloads recommendations', async () => {
localStorage.setItem('accessToken', 'token');
let recommendationsCall = 0;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
recommendationsCall += 1;
if (recommendationsCall === 1) {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }, { id: 'rec-2' }] });
}
return makeJsonResponse({ recommendations: [{ id: 'rec-restored' }] });
}
if (url === '/api/bookdate/swipe') {
return makeJsonResponse({ success: true });
}
if (url === '/api/bookdate/undo') {
return makeJsonResponse({ success: true });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
fireEvent.click(await screen.findByRole('button', { name: 'Swipe Left' }));
const undoButton = await screen.findByRole('button', { name: /Undo/i });
fireEvent.click(undoButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/bookdate/undo',
expect.objectContaining({ method: 'POST' })
);
});
await waitFor(() => {
expect(screen.getByTestId('card-count')).toHaveTextContent('1');
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Undo/i })).toBeNull();
});
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Component: Home Page Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation';
const useAudiobooksMock = vi.hoisted(() => vi.fn());
const usePreferencesMock = vi.hoisted(() => ({
cardSize: 5,
setCardSize: vi.fn(),
}));
vi.mock('@/lib/hooks/useAudiobooks', () => ({
useAudiobooks: useAudiobooksMock,
}));
vi.mock('@/contexts/PreferencesContext', () => ({
usePreferences: () => usePreferencesMock,
}));
vi.mock('@/components/auth/ProtectedRoute', () => ({
ProtectedRoute: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@/components/layout/Header', () => ({
Header: () => <div data-testid="header" />,
}));
vi.mock('@/components/audiobooks/AudiobookGrid', () => ({
AudiobookGrid: ({ audiobooks, cardSize }: { audiobooks: any[]; cardSize?: number }) => (
<div data-testid="grid" data-count={audiobooks.length} data-size={cardSize}>
{audiobooks.map((book) => (
<div key={book.asin}>{book.title}</div>
))}
</div>
),
}));
vi.mock('@/components/ui/CardSizeControls', () => ({
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
}));
vi.mock('@/components/ui/StickyPagination', () => ({
StickyPagination: ({
label,
onPageChange,
}: {
label: string;
onPageChange: (page: number) => void;
}) => (
<button type="button" onClick={() => onPageChange(2)}>
{label} next
</button>
),
}));
describe('HomePage', () => {
beforeEach(() => {
resetMockAuthState();
resetMockRouter();
useAudiobooksMock.mockReset();
usePreferencesMock.cardSize = 5;
usePreferencesMock.setCardSize.mockReset();
vi.resetModules();
});
it('renders empty state messaging for popular audiobooks', async () => {
useAudiobooksMock.mockImplementation((category: string) => {
if (category === 'popular') {
return {
audiobooks: [],
isLoading: false,
totalPages: 1,
message: 'Nothing here',
};
}
return {
audiobooks: [{ asin: 'n1', title: 'New Release', author: 'Author' }],
isLoading: false,
totalPages: 2,
message: null,
};
});
const { default: HomePage } = await import('@/app/page');
render(<HomePage />);
expect(screen.getByText('No popular audiobooks found')).toBeInTheDocument();
expect(screen.getByText('Nothing here')).toBeInTheDocument();
expect(screen.getByText('New Release')).toBeInTheDocument();
});
it('updates pagination when the sticky controls request a new page', async () => {
useAudiobooksMock.mockImplementation((category: string, _limit: number, page: number) => {
return {
audiobooks: [{ asin: `${category}-${page}`, title: `${category}-${page}`, author: 'Author' }],
isLoading: false,
totalPages: 3,
message: null,
};
});
const { default: HomePage } = await import('@/app/page');
render(<HomePage />);
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
await waitFor(() => {
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2);
});
});
});
+523
View File
@@ -0,0 +1,523 @@
/**
* Component: Login Page Tests
* Documentation: documentation/frontend/pages/login.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { resetMockRouter, routerMock, setMockSearchParams } from '../helpers/mock-next-navigation';
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
const makeJsonResponse = (body: any, ok: boolean = true) => ({
ok,
status: ok ? 200 : 500,
json: async () => body,
});
const baseProviders = {
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
hasLocalUsers: false,
oidcProviderName: null,
localLoginDisabled: false,
automationEnabled: false,
};
describe('LoginPage', () => {
beforeEach(() => {
resetMockRouter();
resetMockAuthState();
localStorage.clear();
setMockSearchParams('');
window.innerWidth = 1024;
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders description based on backend mode and automation flag', async () => {
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') {
return makeJsonResponse({
...baseProviders,
backendMode: 'audiobookshelf',
automationEnabled: true,
});
}
if (url === '/api/audiobooks/covers') {
return makeJsonResponse({ success: true, covers: [] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
expect(
await screen.findByText(
"Request audiobooks and they'll automatically download and appear in your Audiobookshelf library"
)
).toBeInTheDocument();
});
it('redirects to intended page when user is already logged in', async () => {
setMockAuthState({
user: { id: 'user-1', plexId: 'plex-1', username: 'user', role: 'user' },
isLoading: false,
});
setMockSearchParams('redirect=/requests');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
await waitFor(() => {
expect(routerMock.push).toHaveBeenCalledWith('/requests');
});
});
it('handles Plex login with popup flow', async () => {
const loginMock = vi.fn().mockResolvedValue(undefined);
setMockAuthState({ login: loginMock, isLoading: false });
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
if (url === '/api/auth/plex/login') {
return makeJsonResponse({ pinId: 123, authUrl: 'http://plex/auth' });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const closeMock = vi.fn();
const openMock = vi.fn().mockReturnValue({ close: closeMock });
vi.stubGlobal('open', openMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
const loginButton = await screen.findByRole('button', { name: 'Login with Plex' });
fireEvent.click(loginButton);
await waitFor(() => {
expect(loginMock).toHaveBeenCalledWith(123);
expect(routerMock.push).toHaveBeenCalledWith('/');
});
expect(openMock).toHaveBeenCalledWith(
'http://plex/auth',
'plex-auth',
'width=600,height=700,scrollbars=yes,resizable=yes'
);
expect(closeMock).toHaveBeenCalled();
});
it('shows an error when Plex login popup is blocked', async () => {
const loginMock = vi.fn().mockResolvedValue(undefined);
setMockAuthState({ login: loginMock, isLoading: false });
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
if (url === '/api/auth/plex/login') {
return makeJsonResponse({ pinId: 456, authUrl: 'http://plex/auth' });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('open', vi.fn().mockReturnValue(null));
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
const loginButton = await screen.findByRole('button', { name: 'Login with Plex' });
fireEvent.click(loginButton);
expect(await screen.findByText(/Popup was blocked/i)).toBeInTheDocument();
expect(loginMock).not.toHaveBeenCalled();
});
it('logs in with local credentials and stores tokens', async () => {
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
const providers = {
...baseProviders,
providers: ['local'],
};
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(providers);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
if (url === '/api/auth/local/login') {
return makeJsonResponse({
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: { id: 'user-1', username: 'local-user', role: 'admin' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
const username = await screen.findByLabelText('Username');
const password = screen.getByLabelText('Password');
fireEvent.change(username, { target: { value: 'admin' } });
fireEvent.change(password, { target: { value: 'secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Login' }));
await waitFor(() => {
expect(setAuthDataMock).toHaveBeenCalledWith(
{ id: 'user-1', username: 'local-user', role: 'admin' },
'access-token'
);
expect(routerMock.push).toHaveBeenCalledWith('/');
});
expect(localStorage.getItem('accessToken')).toBe('access-token');
expect(localStorage.getItem('refreshToken')).toBe('refresh-token');
});
it('validates registration passwords before sending request', async () => {
const providers = {
...baseProviders,
providers: ['local'],
registrationEnabled: true,
};
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(providers);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
const registerToggle = await screen.findByRole('button', { name: /Don't have an account\? Register/i });
fireEvent.click(registerToggle);
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
const passwordInputs = screen.getAllByLabelText('Password');
fireEvent.change(passwordInputs[0], { target: { value: 'password1' } });
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password2' } });
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
expect(await screen.findByText('Passwords do not match')).toBeInTheDocument();
});
it('renders an OIDC login button and redirects to the provider', async () => {
const providers = {
...baseProviders,
providers: ['oidc'],
oidcProviderName: 'Auth0',
};
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(providers);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
expect(await screen.findByRole('button', { name: 'Login with Auth0' })).toBeInTheDocument();
expect(
screen.getByText("You'll be redirected to Auth0 to authenticate")
).toBeInTheDocument();
});
it('logs in via admin credentials when Plex mode exposes admin login', async () => {
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
if (url === '/api/auth/admin/login') {
return makeJsonResponse({
accessToken: 'admin-access',
refreshToken: 'admin-refresh',
user: { id: 'admin-1', username: 'admin', role: 'admin' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
const toggleButton = await screen.findByRole('button', { name: 'Admin Login' });
fireEvent.click(toggleButton);
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' }));
await waitFor(() => {
expect(setAuthDataMock).toHaveBeenCalledWith(
{ id: 'admin-1', username: 'admin', role: 'admin' },
'admin-access'
);
expect(routerMock.push).toHaveBeenCalledWith('/');
});
expect(localStorage.getItem('accessToken')).toBe('admin-access');
expect(localStorage.getItem('refreshToken')).toBe('admin-refresh');
});
it('renders book cover images when the covers API returns data', async () => {
window.innerWidth = 500;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') {
return makeJsonResponse({
success: true,
covers: [
{
asin: 'asin-1',
title: 'Book One',
author: 'Author',
coverUrl: '/cover.jpg',
},
],
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
expect(await screen.findByAltText('Book One')).toBeInTheDocument();
});
it('shows pending approval alert when admin login returns pending status', async () => {
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
if (url === '/api/auth/admin/login') {
return makeJsonResponse({ pendingApproval: true });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Admin Login' }));
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' }));
expect(await screen.findByText('Account Pending Approval')).toBeInTheDocument();
expect(setAuthDataMock).not.toHaveBeenCalled();
});
it('shows registration pending alert when registration needs approval', async () => {
const providers = {
...baseProviders,
providers: ['local'],
registrationEnabled: true,
};
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(providers);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
if (url === '/api/auth/register') {
return makeJsonResponse({ pendingApproval: true });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
fireEvent.click(
await screen.findByRole('button', { name: /Don't have an account\? Register/i })
);
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } });
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } });
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
expect(await screen.findByText('Registration Pending')).toBeInTheDocument();
expect(setAuthDataMock).not.toHaveBeenCalled();
});
it('auto-logs in after successful registration', async () => {
const providers = {
...baseProviders,
providers: ['local'],
registrationEnabled: true,
};
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(providers);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
if (url === '/api/auth/register') {
return makeJsonResponse({
success: true,
accessToken: 'reg-access',
refreshToken: 'reg-refresh',
user: { id: 'user-3', username: 'new-user', role: 'user' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
fireEvent.click(
await screen.findByRole('button', { name: /Don't have an account\? Register/i })
);
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } });
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } });
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
await waitFor(() => {
expect(setAuthDataMock).toHaveBeenCalledWith(
{ id: 'user-3', username: 'new-user', role: 'user' },
'reg-access'
);
expect(routerMock.push).toHaveBeenCalledWith('/');
});
expect(localStorage.getItem('accessToken')).toBe('reg-access');
expect(localStorage.getItem('refreshToken')).toBe('reg-refresh');
});
it('falls back to Plex mode when providers fetch fails', async () => {
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') {
throw new Error('providers down');
}
if (url === '/api/audiobooks/covers') {
throw new Error('covers down');
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
expect(await screen.findByRole('button', { name: 'Login with Plex' })).toBeInTheDocument();
expect(errorMock).toHaveBeenCalledWith('Failed to fetch auth providers:', expect.any(Error));
expect(errorMock).toHaveBeenCalledWith('Failed to fetch book covers:', expect.any(Error));
});
it('processes mobile auth data from URL hash', async () => {
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
setMockSearchParams('auth=success&redirect=/requests');
const authData = {
accessToken: 'mobile-access',
refreshToken: 'mobile-refresh',
user: { id: 'user-9', username: 'mobile-user', role: 'user' },
};
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
await waitFor(() => {
expect(setAuthDataMock).toHaveBeenCalledWith(authData.user, authData.accessToken);
expect(routerMock.push).toHaveBeenCalledWith('/requests');
});
expect(localStorage.getItem('accessToken')).toBe('mobile-access');
expect(localStorage.getItem('refreshToken')).toBe('mobile-refresh');
expect(window.location.hash).toBe('');
});
it('shows error message from query string', async () => {
setMockSearchParams('error=Access%20Denied');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: LoginPage } = await import('@/app/login/page');
render(<LoginPage />);
expect(await screen.findByText('Access Denied')).toBeInTheDocument();
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* Component: Profile Page Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation';
const useRequestsMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/hooks/useRequests', () => ({
useRequests: useRequestsMock,
}));
vi.mock('@/components/layout/Header', () => ({
Header: () => <div data-testid="header" />,
}));
vi.mock('@/components/requests/RequestCard', () => ({
RequestCard: ({ request, showActions }: { request: any; showActions?: boolean }) => (
<div
data-testid="request-card"
data-request-id={request.id}
data-show-actions={String(!!showActions)}
>
{request.id}
</div>
),
}));
const getStatValue = (label: string) => {
const labelNode = screen.getByText(label);
const container = labelNode.parentElement;
const valueNode = container?.querySelector('p:nth-of-type(2)');
return valueNode?.textContent;
};
describe('ProfilePage', () => {
beforeEach(() => {
resetMockAuthState();
resetMockRouter();
useRequestsMock.mockReset();
vi.resetModules();
});
it('prompts for authentication when no user is available', async () => {
setMockAuthState({ user: null });
useRequestsMock.mockReturnValue({ requests: [], isLoading: false });
const { default: ProfilePage } = await import('@/app/profile/page');
render(<ProfilePage />);
expect(screen.getByText('Authentication Required')).toBeInTheDocument();
expect(screen.getByText('Please log in to view your profile')).toBeInTheDocument();
});
it('calculates stats and orders recent requests', async () => {
setMockAuthState({
user: {
id: 'user-1',
plexId: 'plex-1',
username: 'user',
role: 'user',
},
isLoading: false,
});
const requests = [
{ id: 'req-1', status: 'pending', createdAt: '2025-01-01T10:00:00Z', audiobook: {} },
{ id: 'req-2', status: 'awaiting_search', createdAt: '2025-01-02T10:00:00Z', audiobook: {} },
{ id: 'req-3', status: 'available', createdAt: '2025-01-03T10:00:00Z', audiobook: {} },
{ id: 'req-4', status: 'failed', createdAt: '2025-01-04T10:00:00Z', audiobook: {} },
{ id: 'req-5', status: 'cancelled', createdAt: '2025-01-05T10:00:00Z', audiobook: {} },
{ id: 'req-6', status: 'searching', createdAt: '2025-01-06T10:00:00Z', audiobook: {} },
];
useRequestsMock.mockReturnValue({ requests, isLoading: false });
const { default: ProfilePage } = await import('@/app/profile/page');
render(<ProfilePage />);
expect(getStatValue('Total')).toBe('6');
expect(getStatValue('Active')).toBe('2');
expect(getStatValue('Waiting')).toBe('1');
expect(getStatValue('Completed')).toBe('1');
expect(getStatValue('Failed')).toBe('1');
expect(getStatValue('Cancelled')).toBe('1');
const cards = screen.getAllByTestId('request-card');
expect(cards).toHaveLength(5);
expect(cards[0]).toHaveAttribute('data-request-id', 'req-6');
});
it('shows active downloads when downloading requests exist', async () => {
setMockAuthState({
user: {
id: 'user-2',
plexId: 'plex-2',
username: 'download-user',
role: 'user',
},
isLoading: false,
});
const requests = [
{ id: 'req-downloading', status: 'downloading', createdAt: '2025-02-01T10:00:00Z', audiobook: {} },
{ id: 'req-processing', status: 'processing', createdAt: '2025-02-02T10:00:00Z', audiobook: {} },
{ id: 'req-pending', status: 'pending', createdAt: '2025-02-03T10:00:00Z', audiobook: {} },
];
useRequestsMock.mockReturnValue({ requests, isLoading: false });
const { default: ProfilePage } = await import('@/app/profile/page');
render(<ProfilePage />);
expect(screen.getByText('Active Downloads')).toBeInTheDocument();
const cards = screen.getAllByTestId('request-card');
expect(cards.length).toBeGreaterThan(0);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Component: Requests Page Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation';
const useRequestsMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/hooks/useRequests', () => ({
useRequests: useRequestsMock,
}));
vi.mock('@/components/layout/Header', () => ({
Header: () => <div data-testid="header" />,
}));
vi.mock('@/components/requests/RequestCard', () => ({
RequestCard: ({ request, showActions }: { request: any; showActions?: boolean }) => (
<div
data-testid="request-card"
data-status={request.status}
data-show-actions={String(!!showActions)}
>
{request.id}
</div>
),
}));
describe('RequestsPage', () => {
beforeEach(() => {
resetMockAuthState();
resetMockRouter();
useRequestsMock.mockReset();
vi.resetModules();
});
it('prompts for authentication when no user is available', async () => {
setMockAuthState({ user: null });
useRequestsMock.mockReturnValue({ requests: [], isLoading: false });
const { default: RequestsPage } = await import('@/app/requests/page');
render(<RequestsPage />);
expect(screen.getByText('Authentication Required')).toBeInTheDocument();
expect(screen.getByText('Please log in to view your audiobook requests')).toBeInTheDocument();
});
it('filters requests by status and updates tab counts', async () => {
setMockAuthState({
user: { id: 'user-1', plexId: 'plex-1', username: 'user', role: 'user' },
isLoading: false,
});
const requests = [
{ id: 'req-active', status: 'pending', audiobook: { title: 'Active', author: 'Author' } },
{ id: 'req-wait', status: 'awaiting_search', audiobook: { title: 'Wait', author: 'Author' } },
{ id: 'req-complete', status: 'downloaded', audiobook: { title: 'Done', author: 'Author' } },
{ id: 'req-failed', status: 'failed', audiobook: { title: 'Fail', author: 'Author' } },
];
useRequestsMock.mockReturnValue({ requests, isLoading: false });
const { default: RequestsPage } = await import('@/app/requests/page');
render(<RequestsPage />);
const activeTab = screen.getByRole('button', { name: /Active/i });
const waitingTab = screen.getByRole('button', { name: /Waiting/i });
expect(activeTab).toHaveTextContent('(1)');
expect(waitingTab).toHaveTextContent('(1)');
fireEvent.click(activeTab);
const activeCards = screen.getAllByTestId('request-card');
expect(activeCards).toHaveLength(1);
expect(activeCards[0]).toHaveAttribute('data-status', 'pending');
fireEvent.click(waitingTab);
const waitingCards = screen.getAllByTestId('request-card');
expect(waitingCards).toHaveLength(1);
expect(waitingCards[0]).toHaveAttribute('data-status', 'awaiting_search');
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* Component: Search Page Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation';
const useSearchMock = vi.hoisted(() => vi.fn());
const usePreferencesMock = vi.hoisted(() => ({
cardSize: 5,
setCardSize: vi.fn(),
}));
vi.mock('@/lib/hooks/useAudiobooks', () => ({
useSearch: useSearchMock,
}));
vi.mock('@/contexts/PreferencesContext', () => ({
usePreferences: () => usePreferencesMock,
}));
vi.mock('@/components/auth/ProtectedRoute', () => ({
ProtectedRoute: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@/components/layout/Header', () => ({
Header: () => <div data-testid="header" />,
}));
vi.mock('@/components/audiobooks/AudiobookGrid', () => ({
AudiobookGrid: ({
audiobooks,
emptyMessage,
cardSize,
}: {
audiobooks: any[];
emptyMessage: string;
cardSize?: number;
}) => (
<div data-testid="grid" data-count={audiobooks.length} data-size={cardSize}>
<span>{emptyMessage}</span>
</div>
),
}));
vi.mock('@/components/ui/CardSizeControls', () => ({
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
}));
describe('SearchPage', () => {
beforeEach(() => {
resetMockAuthState();
resetMockRouter();
useSearchMock.mockReset();
usePreferencesMock.cardSize = 5;
usePreferencesMock.setCardSize.mockReset();
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers();
});
it('shows the empty state before a search query is entered', async () => {
useSearchMock.mockReturnValue({
results: [],
totalResults: 0,
hasMore: false,
isLoading: false,
});
const { default: SearchPage } = await import('@/app/search/page');
render(<SearchPage />);
expect(screen.getByText('Start typing to search for audiobooks')).toBeInTheDocument();
expect(useSearchMock).toHaveBeenCalledWith('', 1);
});
it('debounces search input and loads more results', async () => {
useSearchMock.mockImplementation((query: string, page: number) => {
if (!query) {
return { results: [], totalResults: 0, hasMore: false, isLoading: false };
}
if (page === 1) {
return {
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
totalResults: 2,
hasMore: true,
isLoading: false,
};
}
return {
results: [{ asin: 'a2', title: 'Book Two', author: 'Author' }],
totalResults: 2,
hasMore: false,
isLoading: false,
};
});
const { default: SearchPage } = await import('@/app/search/page');
render(<SearchPage />);
const input = screen.getByPlaceholderText('Search by title, author, or narrator...');
fireEvent.change(input, { target: { value: 'Dune' } });
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText('Search Results')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Load More Results' })).toBeInTheDocument();
expect(screen.getByTestId('grid')).toHaveAttribute('data-count', '1');
fireEvent.click(screen.getByRole('button', { name: 'Load More Results' }));
expect(useSearchMock).toHaveBeenCalledWith('Dune', 2);
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* Component: Select Profile Page Tests
* Documentation: documentation/backend/services/auth.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter, routerMock, setMockSearchParams } from '../helpers/mock-next-navigation';
const makeJsonResponse = (body: any, ok = true, status = 200) => ({
ok,
status,
json: async () => body,
});
describe('SelectProfilePage', () => {
beforeEach(() => {
resetMockAuthState();
resetMockRouter();
localStorage.clear();
sessionStorage.clear();
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('shows an error when session info is missing', async () => {
setMockSearchParams('');
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page');
render(<SelectProfilePage />);
expect(await screen.findByText('Invalid session. Please try logging in again.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back to Login' }));
expect(routerMock.push).toHaveBeenCalledWith('/login');
});
it('selects an unprotected profile and stores auth data', async () => {
sessionStorage.setItem('plex_main_token', 'main-token');
setMockSearchParams('pinId=123');
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
const profiles = [
{
id: 'profile-1',
uuid: 'uuid-1',
title: 'User',
friendlyName: 'Primary',
username: 'primary',
email: 'primary@example.com',
thumb: 'http://thumb',
hasPassword: false,
restricted: false,
admin: true,
guest: false,
protected: false,
},
];
const fetchMock = vi.fn(async (input: RequestInfo, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/plex/home-users') {
return makeJsonResponse({ users: profiles });
}
if (url === '/api/auth/plex/switch-profile') {
return makeJsonResponse({
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: { id: 'user-1', plexId: 'plex-1', username: 'primary', role: 'user' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page');
render(<SelectProfilePage />);
const profileButton = await screen.findByRole('button', { name: /Primary/ });
fireEvent.click(profileButton);
await waitFor(() => {
expect(setAuthDataMock).toHaveBeenCalledWith(
{ id: 'user-1', plexId: 'plex-1', username: 'primary', role: 'user' },
'access-token'
);
expect(routerMock.push).toHaveBeenCalledWith('/');
});
const body = JSON.parse((fetchMock.mock.calls[1][1] as RequestInit).body as string);
expect(body.userId).toBe('profile-1');
expect(body.pinId).toBe('123');
expect(localStorage.getItem('accessToken')).toBe('access-token');
expect(localStorage.getItem('refreshToken')).toBe('refresh-token');
});
it('prompts for a PIN and handles invalid submissions', async () => {
sessionStorage.setItem('plex_main_token', 'main-token');
setMockSearchParams('pinId=555');
const setAuthDataMock = vi.fn();
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
const profiles = [
{
id: 'profile-2',
uuid: 'uuid-2',
title: 'Protected',
friendlyName: 'Protected',
username: 'protected',
email: 'protected@example.com',
thumb: '',
hasPassword: true,
restricted: false,
admin: false,
guest: false,
protected: true,
},
];
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/plex/home-users') {
return makeJsonResponse({ users: profiles });
}
if (url === '/api/auth/plex/switch-profile') {
return makeJsonResponse({ message: 'Invalid PIN' }, false, 401);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page');
render(<SelectProfilePage />);
const profileButton = await screen.findByRole('button', { name: /Protected/ });
fireEvent.click(profileButton);
const pinInput = await screen.findByPlaceholderText('Enter PIN');
fireEvent.change(pinInput, { target: { value: '1234' } });
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(await screen.findByText('Invalid PIN. Please try again.')).toBeInTheDocument();
expect((pinInput as HTMLInputElement).value).toBe('');
expect(setAuthDataMock).not.toHaveBeenCalled();
});
});
+263
View File
@@ -0,0 +1,263 @@
/**
* Component: Setup Wizard Page Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import path from 'path';
import { resetMockRouter } from '../helpers/mock-next-navigation';
const mockSetupModules = () => {
vi.doMock(path.resolve('src/app/setup/components/WizardLayout.tsx'), () => ({
WizardLayout: ({
children,
currentStep,
totalSteps,
}: {
children: React.ReactNode;
currentStep: number;
totalSteps: number;
}) => (
<div data-testid="wizard" data-step={currentStep} data-total={totalSteps}>
{children}
</div>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/WelcomeStep.tsx'), () => ({
WelcomeStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/BackendSelectionStep.tsx'), () => ({
BackendSelectionStep: ({
onNext,
onChange,
}: {
onNext: () => void;
onChange: (value: 'plex' | 'audiobookshelf') => void;
}) => (
<div>
<button type="button" onClick={() => onChange('plex')}>
Choose Plex
</button>
<button type="button" onClick={() => onChange('audiobookshelf')}>
Choose ABS
</button>
<button type="button" onClick={onNext}>
Next
</button>
</div>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/AdminAccountStep.tsx'), () => ({
AdminAccountStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/PlexStep.tsx'), () => ({
PlexStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/AudiobookshelfStep.tsx'), () => ({
AudiobookshelfStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/AuthMethodStep.tsx'), () => ({
AuthMethodStep: ({
onNext,
onChange,
}: {
onNext: () => void;
onChange: (value: 'oidc' | 'manual' | 'both') => void;
}) => (
<div>
<button type="button" onClick={() => onChange('oidc')}>
Choose OIDC
</button>
<button type="button" onClick={onNext}>
Next
</button>
</div>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/OIDCConfigStep.tsx'), () => ({
OIDCConfigStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/RegistrationSettingsStep.tsx'), () => ({
RegistrationSettingsStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/ProwlarrStep.tsx'), () => ({
ProwlarrStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/DownloadClientStep.tsx'), () => ({
DownloadClientStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/PathsStep.tsx'), () => ({
PathsStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/BookDateStep.tsx'), () => ({
BookDateStep: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Next
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/ReviewStep.tsx'), () => ({
ReviewStep: ({ onComplete }: { onComplete: () => void }) => (
<button type="button" onClick={onComplete}>
Complete
</button>
),
}));
vi.doMock(path.resolve('src/app/setup/steps/FinalizeStep.tsx'), () => ({
FinalizeStep: ({ hasAdminTokens }: { hasAdminTokens: boolean }) => (
<div data-testid="finalize">{hasAdminTokens ? 'admin' : 'oidc'}</div>
),
}));
};
const makeJsonResponse = (body: any, ok: boolean = true) => ({
ok,
status: ok ? 200 : 500,
json: async () => body,
});
describe('SetupWizard', () => {
beforeEach(() => {
resetMockRouter();
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('completes setup in Plex mode and stores admin tokens', async () => {
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/setup/complete') {
return makeJsonResponse({
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: { id: 'admin-1', username: 'admin' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.resetModules();
mockSetupModules();
const { default: SetupWizard } = await import('@/app/setup/page');
render(<SetupWizard />);
for (let i = 0; i < 8; i += 1) {
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
}
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
await waitFor(() => {
expect(localStorage.getItem('accessToken')).toBe('access-token');
expect(screen.getByTestId('finalize')).toHaveTextContent('admin');
});
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(requestBody.backendMode).toBe('plex');
expect(requestBody.admin).toBeDefined();
expect(requestBody.plex).toBeDefined();
});
it('completes setup in OIDC-only mode and clears tokens', async () => {
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/setup/complete') {
return makeJsonResponse({ success: true });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
localStorage.setItem('accessToken', 'stale-token');
vi.resetModules();
mockSetupModules();
const { default: SetupWizard } = await import('@/app/setup/page');
render(<SetupWizard />);
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Choose ABS' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
fireEvent.click(await screen.findByRole('button', { name: 'Choose OIDC' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
for (let i = 0; i < 5; i += 1) {
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
}
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
await waitFor(() => {
expect(localStorage.getItem('accessToken')).toBeNull();
expect(screen.getByTestId('finalize')).toHaveTextContent('oidc');
});
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(requestBody.backendMode).toBe('audiobookshelf');
expect(requestBody.authMethod).toBe('oidc');
expect(requestBody.audiobookshelf).toBeDefined();
expect(requestBody.oidc).toBeDefined();
expect(requestBody.admin).toBeUndefined();
});
});
@@ -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();
});
});