mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -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' }),
|
||||
[],
|
||||
[]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user