mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Implement file hash-based library matching and remove fuzzy ASIN matching
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Component: Admin Jobs Page Tests
|
||||
* Documentation: documentation/backend/services/scheduler.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 AdminJobsPage from '@/app/admin/jobs/page';
|
||||
|
||||
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
|
||||
const fetchJSONMock = vi.hoisted(() => vi.fn());
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: 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('AdminJobsPage', () => {
|
||||
beforeEach(() => {
|
||||
authenticatedFetcherMock.mockReset();
|
||||
fetchJSONMock.mockReset();
|
||||
toastMock.success.mockReset();
|
||||
toastMock.error.mockReset();
|
||||
});
|
||||
|
||||
it('renders scheduled jobs and allows manual trigger', async () => {
|
||||
authenticatedFetcherMock.mockResolvedValue({
|
||||
jobs: [
|
||||
{
|
||||
id: 'job-1',
|
||||
name: 'Library Scan',
|
||||
type: 'scan_plex',
|
||||
schedule: '0 * * * *',
|
||||
enabled: true,
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchJSONMock.mockResolvedValue({ success: true });
|
||||
|
||||
render(<AdminJobsPage />);
|
||||
|
||||
expect(await screen.findByText('Library Scan')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Trigger Now/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trigger Job' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/jobs/job-1/trigger', {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Job "Library Scan" triggered successfully');
|
||||
});
|
||||
|
||||
expect(authenticatedFetcherMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('updates a job schedule using preset selection', async () => {
|
||||
authenticatedFetcherMock.mockResolvedValue({
|
||||
jobs: [
|
||||
{
|
||||
id: 'job-2',
|
||||
name: 'Audible Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 * * * *',
|
||||
enabled: true,
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchJSONMock.mockResolvedValue({ success: true });
|
||||
|
||||
render(<AdminJobsPage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Every 2 hours/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/jobs/job-2', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ schedule: '0 */2 * * *', enabled: true }),
|
||||
});
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Job "Audible Refresh" updated successfully');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error when jobs fail to load', async () => {
|
||||
authenticatedFetcherMock.mockRejectedValue(new Error('boom'));
|
||||
|
||||
render(<AdminJobsPage />);
|
||||
|
||||
expect(await screen.findByText('Failed to load scheduled jobs')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Component: Admin Logs 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 AdminLogsPage from '@/app/admin/logs/page';
|
||||
|
||||
const useSWRMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
default: (...args: any[]) => useSWRMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
authenticatedFetcher: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AdminLogsPage', () => {
|
||||
beforeEach(() => {
|
||||
useSWRMock.mockReset();
|
||||
});
|
||||
|
||||
it('renders logs and toggles detail rows', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: {
|
||||
logs: [
|
||||
{
|
||||
id: 'log-1',
|
||||
bullJobId: 'bull-1',
|
||||
type: 'search_indexers',
|
||||
status: 'failed',
|
||||
priority: 1,
|
||||
attempts: 2,
|
||||
maxAttempts: 3,
|
||||
errorMessage: 'Search failed',
|
||||
startedAt: '2024-01-01T00:00:00Z',
|
||||
completedAt: '2024-01-01T00:02:00Z',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:02:00Z',
|
||||
result: { retries: 2 },
|
||||
events: [
|
||||
{
|
||||
id: 'event-1',
|
||||
level: 'error',
|
||||
context: 'SearchJob',
|
||||
message: 'Indexer timeout',
|
||||
metadata: { indexer: 'Example' },
|
||||
createdAt: '2024-01-01T00:01:00Z',
|
||||
},
|
||||
],
|
||||
request: {
|
||||
id: 'req-1',
|
||||
audiobook: { title: 'Search Book', author: 'Author' },
|
||||
user: { plexUsername: 'User' },
|
||||
},
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 50, total: 1, totalPages: 1 },
|
||||
},
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText('System Logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Search Book')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show Details' }));
|
||||
expect(screen.getByText('Event Log')).toBeInTheDocument();
|
||||
expect(screen.getByText('Job Result')).toBeInTheDocument();
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide Details' }));
|
||||
expect(screen.queryByText('Event Log')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the swr key when filters change', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } },
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const statusSelect = screen
|
||||
.getByText('Status', { selector: 'label' })
|
||||
.parentElement?.querySelector('select');
|
||||
expect(statusSelect).not.toBeNull();
|
||||
fireEvent.change(statusSelect as HTMLSelectElement, { target: { value: 'completed' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
'/api/admin/logs?page=1&limit=50&status=completed&type=all',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders error state when logs fail to load', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: undefined,
|
||||
error: new Error('Log failure'),
|
||||
}));
|
||||
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText('Error Loading Logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log failure')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no logs are returned', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } },
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText('No logs found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Component: Admin Dashboard 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 AdminDashboard from '@/app/admin/page';
|
||||
|
||||
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
|
||||
const fetchJSONMock = vi.hoisted(() => vi.fn());
|
||||
const mutateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}));
|
||||
|
||||
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() };
|
||||
},
|
||||
mutate: mutateMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
authenticatedFetcher: authenticatedFetcherMock,
|
||||
fetchJSON: fetchJSONMock,
|
||||
fetchWithAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||
InteractiveTorrentSearchModal: () => null,
|
||||
}));
|
||||
|
||||
describe('AdminDashboard', () => {
|
||||
beforeEach(() => {
|
||||
swrState.clear();
|
||||
fetchJSONMock.mockReset();
|
||||
mutateMock.mockReset();
|
||||
toastMock.success.mockReset();
|
||||
toastMock.error.mockReset();
|
||||
});
|
||||
|
||||
it('renders metrics, downloads, and recent requests', async () => {
|
||||
swrState.set('/api/admin/metrics', {
|
||||
data: {
|
||||
totalRequests: 12,
|
||||
activeDownloads: 2,
|
||||
completedLast30Days: 8,
|
||||
failedLast30Days: 1,
|
||||
totalUsers: 4,
|
||||
systemHealth: { status: 'healthy', issues: [] },
|
||||
},
|
||||
});
|
||||
swrState.set('/api/admin/downloads/active', {
|
||||
data: {
|
||||
downloads: [
|
||||
{
|
||||
requestId: 'r1',
|
||||
title: 'Active Book',
|
||||
author: 'Author One',
|
||||
progress: 55,
|
||||
speed: 1024,
|
||||
eta: 1200,
|
||||
user: 'Zach',
|
||||
startedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
swrState.set('/api/admin/requests/recent', {
|
||||
data: {
|
||||
requests: [
|
||||
{
|
||||
requestId: 'req-1',
|
||||
title: 'Recent Book',
|
||||
author: 'Author Two',
|
||||
status: 'pending',
|
||||
user: 'Sam',
|
||||
createdAt: new Date('2024-01-02T00:00:00Z'),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
swrState.set('/api/admin/requests/pending-approval', { data: { requests: [] } });
|
||||
swrState.set('/api/admin/settings', { data: { ebook: { enabled: false } } });
|
||||
|
||||
render(<AdminDashboard />);
|
||||
|
||||
expect(await screen.findByText('Admin Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Requests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active Book')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent Book')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('approves a pending request and refreshes caches', async () => {
|
||||
swrState.set('/api/admin/metrics', {
|
||||
data: {
|
||||
totalRequests: 1,
|
||||
activeDownloads: 0,
|
||||
completedLast30Days: 0,
|
||||
failedLast30Days: 0,
|
||||
totalUsers: 1,
|
||||
systemHealth: { status: 'healthy', issues: [] },
|
||||
},
|
||||
});
|
||||
swrState.set('/api/admin/downloads/active', { data: { downloads: [] } });
|
||||
swrState.set('/api/admin/requests/recent', { data: { requests: [] } });
|
||||
swrState.set('/api/admin/settings', { data: { ebook: { enabled: false } } });
|
||||
swrState.set('/api/admin/requests/pending-approval', {
|
||||
data: {
|
||||
requests: [
|
||||
{
|
||||
id: 'pending-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
audiobook: { title: 'Awaiting', author: 'Author', coverArtUrl: null },
|
||||
user: { id: 'u1', plexUsername: 'User', avatarUrl: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
fetchJSONMock.mockResolvedValue({ success: true });
|
||||
|
||||
render(<AdminDashboard />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Approve' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/requests/pending-1/approve', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'approve' }),
|
||||
});
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Request approved');
|
||||
});
|
||||
|
||||
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/pending-approval');
|
||||
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/recent');
|
||||
expect(mutateMock).toHaveBeenCalledWith('/api/admin/metrics');
|
||||
});
|
||||
|
||||
it('shows an error message when dashboard data fails to load', async () => {
|
||||
swrState.set('/api/admin/metrics', { error: new Error('Metrics unavailable') });
|
||||
|
||||
render(<AdminDashboard />);
|
||||
|
||||
expect(await screen.findByText('Error Loading Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Metrics unavailable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Component: Active Downloads Table Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ActiveDownloadsTable } from '@/app/admin/components/ActiveDownloadsTable';
|
||||
|
||||
describe('ActiveDownloadsTable', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders an empty state when no downloads exist', () => {
|
||||
render(<ActiveDownloadsTable downloads={[]} />);
|
||||
|
||||
expect(screen.getByText('No Active Downloads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders download details with formatted values', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
|
||||
render(
|
||||
<ActiveDownloadsTable
|
||||
downloads={[
|
||||
{
|
||||
requestId: 'req-1',
|
||||
title: 'Active Book',
|
||||
author: 'Author One',
|
||||
progress: 42,
|
||||
speed: 1024 * 1024,
|
||||
eta: 3600,
|
||||
user: 'Zach',
|
||||
startedAt: new Date('2023-12-31T23:00:00Z'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Active Book')).toBeInTheDocument();
|
||||
expect(screen.getByText('Author One')).toBeInTheDocument();
|
||||
expect(screen.getByText('42%')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 MB/s')).toBeInTheDocument();
|
||||
expect(screen.getByText('1h 0m')).toBeInTheDocument();
|
||||
expect(screen.getByText(/ago/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Component: Confirm Dialog Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen={false}
|
||||
title="Delete"
|
||||
message="Confirm?"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes confirm and cancel actions', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<ConfirmDialog
|
||||
isOpen
|
||||
title="Delete"
|
||||
message="Confirm?"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
const backdrop = container.querySelector('[aria-hidden="true"]');
|
||||
expect(backdrop).not.toBeNull();
|
||||
if (backdrop) {
|
||||
fireEvent.click(backdrop);
|
||||
}
|
||||
expect(onCancel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Component: Metric Card Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MetricCard } from '@/app/admin/components/MetricCard';
|
||||
|
||||
describe('MetricCard', () => {
|
||||
it('renders title, value, and subtitle with variant styles', () => {
|
||||
const { container } = render(
|
||||
<MetricCard
|
||||
title="Errors"
|
||||
value={3}
|
||||
subtitle="Last 24h"
|
||||
variant="error"
|
||||
icon={<span>!</span>}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Errors')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last 24h')).toBeInTheDocument();
|
||||
expect(container.firstChild).toHaveClass('bg-red-50');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Component: Recent Requests Table Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import path from 'path';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
const mutateMock = vi.hoisted(() => vi.fn());
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
mutate: mutateMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
let RecentRequestsTable: typeof import('@/app/admin/components/RecentRequestsTable').RecentRequestsTable;
|
||||
|
||||
describe('RecentRequestsTable', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fetchWithAuthMock.mockReset();
|
||||
mutateMock.mockReset();
|
||||
toastMock.success.mockReset();
|
||||
toastMock.error.mockReset();
|
||||
toastMock.warning.mockReset();
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/components/RequestActionsDropdown.tsx'), () => ({
|
||||
RequestActionsDropdown: ({
|
||||
request,
|
||||
onDelete,
|
||||
onManualSearch,
|
||||
onCancel,
|
||||
onFetchEbook,
|
||||
isLoading,
|
||||
}: {
|
||||
request: { requestId: string; title: string };
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
onManualSearch: (requestId: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onFetchEbook?: (requestId: string) => void;
|
||||
isLoading?: boolean;
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onDelete(request.requestId, request.title)}>
|
||||
Delete Trigger
|
||||
</button>
|
||||
<button type="button" onClick={() => onManualSearch(request.requestId)}>
|
||||
Manual Search Trigger
|
||||
</button>
|
||||
<button type="button" onClick={() => onCancel(request.requestId)}>
|
||||
Cancel Trigger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFetchEbook?.(request.requestId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Fetch Ebook Trigger
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const module = await import('@/app/admin/components/RecentRequestsTable');
|
||||
RecentRequestsTable = module.RecentRequestsTable;
|
||||
});
|
||||
|
||||
it('shows empty state when there are no requests', () => {
|
||||
render(<RecentRequestsTable requests={[]} />);
|
||||
|
||||
expect(screen.getByText('No Recent Requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a request and refreshes caches', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
render(
|
||||
<RecentRequestsTable
|
||||
requests={[
|
||||
{
|
||||
requestId: 'req-1',
|
||||
title: 'Delete Me',
|
||||
author: 'Author',
|
||||
status: 'pending',
|
||||
user: 'User',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Trigger' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/requests/req-1', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/recent');
|
||||
expect(mutateMock).toHaveBeenCalledWith('/api/admin/metrics');
|
||||
|
||||
const predicateCall = mutateMock.mock.calls.find(
|
||||
(call) => typeof call[0] === 'function'
|
||||
);
|
||||
expect(predicateCall).toBeTruthy();
|
||||
const predicate = predicateCall?.[0] as (key: unknown) => boolean;
|
||||
expect(predicate('/api/audiobooks?query=test')).toBe(true);
|
||||
expect(predicate('/api/other')).toBe(false);
|
||||
});
|
||||
|
||||
it('warns when ebook fetch fails', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: false, message: 'No ebook available' }),
|
||||
});
|
||||
|
||||
render(
|
||||
<RecentRequestsTable
|
||||
requests={[
|
||||
{
|
||||
requestId: 'req-2',
|
||||
title: 'Needs Ebook',
|
||||
author: 'Author',
|
||||
status: 'downloaded',
|
||||
user: 'User',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]}
|
||||
ebookSidecarEnabled
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fetch Ebook Trigger' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-2/fetch-ebook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(toastMock.warning).toHaveBeenCalledWith(
|
||||
'E-book fetch failed: No ebook available'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Component: Request Actions Dropdown Tests
|
||||
* Documentation: documentation/admin-features/request-deletion.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RequestActionsDropdown } from '@/app/admin/components/RequestActionsDropdown';
|
||||
|
||||
vi.mock('@/hooks/useSmartDropdownPosition', () => ({
|
||||
useSmartDropdownPosition: () => ({
|
||||
containerRef: { current: null },
|
||||
dropdownRef: { current: null },
|
||||
positionAbove: false,
|
||||
style: { position: 'fixed', top: 0, left: 0, minWidth: 120 },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||
InteractiveTorrentSearchModal: ({
|
||||
isOpen,
|
||||
audiobook,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
audiobook: { title: string; author: string };
|
||||
}) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null),
|
||||
}));
|
||||
|
||||
describe('RequestActionsDropdown', () => {
|
||||
it('exposes manual search, interactive search, cancel, and delete actions', async () => {
|
||||
const onManualSearch = vi.fn().mockResolvedValue(undefined);
|
||||
const onCancel = vi.fn().mockResolvedValue(undefined);
|
||||
const onDelete = vi.fn();
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<RequestActionsDropdown
|
||||
request={{
|
||||
requestId: 'req-1',
|
||||
title: 'Pending Book',
|
||||
author: 'Author',
|
||||
status: 'pending',
|
||||
}}
|
||||
onManualSearch={onManualSearch}
|
||||
onCancel={onCancel}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
|
||||
expect(screen.getByText('Manual Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Interactive Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel Request')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete Request')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Manual Search'));
|
||||
await waitFor(() => expect(onManualSearch).toHaveBeenCalledWith('req-1'));
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
fireEvent.click(screen.getByText('Interactive Search'));
|
||||
expect(screen.getByText('Interactive search for Pending Book')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
fireEvent.click(screen.getByText('Cancel Request'));
|
||||
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('req-1'));
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
fireEvent.click(screen.getByText('Delete Request'));
|
||||
expect(onDelete).toHaveBeenCalledWith('req-1', 'Pending Book');
|
||||
});
|
||||
|
||||
it('shows view source and ebook fetch when available', async () => {
|
||||
const onFetchEbook = vi.fn().mockResolvedValue(undefined);
|
||||
const onDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<RequestActionsDropdown
|
||||
request={{
|
||||
requestId: 'req-2',
|
||||
title: 'Downloaded Book',
|
||||
author: 'Author',
|
||||
status: 'downloaded',
|
||||
torrentUrl: 'https://example.com/torrent',
|
||||
}}
|
||||
onManualSearch={vi.fn().mockResolvedValue(undefined)}
|
||||
onCancel={vi.fn().mockResolvedValue(undefined)}
|
||||
onDelete={onDelete}
|
||||
onFetchEbook={onFetchEbook}
|
||||
ebookSidecarEnabled
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
|
||||
expect(screen.getByText('View Source')).toBeInTheDocument();
|
||||
expect(screen.getByText('Try to fetch Ebook')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Try to fetch Ebook'));
|
||||
await waitFor(() => expect(onFetchEbook).toHaveBeenCalledWith('req-2'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Component: Admin Settings Global Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const baseSettings = {
|
||||
backendMode: 'plex',
|
||||
hasLocalUsers: true,
|
||||
audibleRegion: 'us',
|
||||
plex: { url: '', token: '', libraryId: '', triggerScanAfterImport: false },
|
||||
audiobookshelf: { serverUrl: '', apiToken: '', libraryId: '', triggerScanAfterImport: false },
|
||||
oidc: {
|
||||
enabled: false,
|
||||
providerName: '',
|
||||
issuerUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
accessControlMethod: 'open',
|
||||
accessGroupClaim: 'groups',
|
||||
accessGroupValue: '',
|
||||
allowedEmails: '["first@example.com","second@example.com"]',
|
||||
allowedUsernames: '["alpha","beta"]',
|
||||
adminClaimEnabled: false,
|
||||
adminClaimName: 'groups',
|
||||
adminClaimValue: '',
|
||||
},
|
||||
registration: { enabled: false, requireAdminApproval: false },
|
||||
prowlarr: { url: '', apiKey: '' },
|
||||
downloadClient: {
|
||||
type: 'qbittorrent',
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
},
|
||||
paths: {
|
||||
downloadDir: '',
|
||||
mediaDir: '',
|
||||
audiobookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
ebook: { enabled: false, preferredFormat: '', baseUrl: '', flaresolverrUrl: '' },
|
||||
};
|
||||
|
||||
describe('useSettings', () => {
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads settings and converts OIDC lists to comma-separated strings', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => baseSettings,
|
||||
});
|
||||
|
||||
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
|
||||
const result = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.settings?.oidc.allowedEmails).toBe('first@example.com, second@example.com');
|
||||
expect(result.current.settings?.oidc.allowedUsernames).toBe('alpha, beta');
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/settings');
|
||||
});
|
||||
|
||||
it('tracks changes, resets, and marks settings as saved', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => baseSettings,
|
||||
});
|
||||
|
||||
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
|
||||
const result = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
result.current.updateSettings({ audibleRegion: 'uk' });
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges()).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.resetSettings();
|
||||
});
|
||||
|
||||
expect(result.current.settings?.audibleRegion).toBe('us');
|
||||
expect(result.current.hasUnsavedChanges()).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.updateSettings((prev) => ({ ...prev, audibleRegion: 'ca' }));
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges()).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.markAsSaved();
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('updates validation, test results, and message state', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => baseSettings,
|
||||
});
|
||||
|
||||
const { useSettings } = await import('@/app/admin/settings/hooks/useSettings');
|
||||
const result = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
result.current.updateValidation('plex', true);
|
||||
result.current.updateTestResults('plex', { success: true, message: 'ok' });
|
||||
result.current.showMessage({ type: 'success', text: 'Saved' });
|
||||
});
|
||||
|
||||
expect(result.current.validated.plex).toBe(true);
|
||||
expect(result.current.testResults.plex).toEqual({ success: true, message: 'ok' });
|
||||
expect(result.current.message?.text).toBe('Saved');
|
||||
|
||||
act(() => {
|
||||
result.current.clearMessage();
|
||||
});
|
||||
|
||||
expect(result.current.message).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Component: Admin Settings Helpers Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const makeOk = () => ({ ok: true });
|
||||
const makeFail = () => ({ ok: false });
|
||||
|
||||
const baseSettings = {
|
||||
backendMode: 'plex',
|
||||
hasLocalUsers: true,
|
||||
hasLocalAdmins: true,
|
||||
audibleRegion: 'us',
|
||||
plex: { url: 'http://plex', token: 'token', libraryId: 'lib', triggerScanAfterImport: false },
|
||||
audiobookshelf: { serverUrl: 'http://abs', apiToken: 'abs-token', libraryId: 'abs-lib', triggerScanAfterImport: false },
|
||||
oidc: {
|
||||
enabled: true,
|
||||
providerName: 'OIDC',
|
||||
issuerUrl: 'http://issuer',
|
||||
clientId: 'client',
|
||||
clientSecret: 'secret',
|
||||
accessControlMethod: 'open',
|
||||
accessGroupClaim: 'groups',
|
||||
accessGroupValue: '',
|
||||
allowedEmails: 'first@example.com, second@example.com',
|
||||
allowedUsernames: 'alpha, beta',
|
||||
adminClaimEnabled: false,
|
||||
adminClaimName: 'groups',
|
||||
adminClaimValue: '',
|
||||
},
|
||||
registration: { enabled: true, requireAdminApproval: false },
|
||||
prowlarr: { url: 'http://prowlarr', apiKey: 'key' },
|
||||
downloadClient: {
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qb',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
},
|
||||
paths: {
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
audiobookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
ebook: { enabled: false, preferredFormat: '', baseUrl: '', flaresolverrUrl: '' },
|
||||
};
|
||||
|
||||
describe('admin settings helpers', () => {
|
||||
it('parses array strings to comma-separated values', async () => {
|
||||
const { parseArrayToCommaSeparated } = await import('@/app/admin/settings/lib/helpers');
|
||||
expect(parseArrayToCommaSeparated('["a","b"]')).toBe('a, b');
|
||||
expect(parseArrayToCommaSeparated('not-json')).toBe('');
|
||||
});
|
||||
|
||||
it('parses comma-separated strings into JSON arrays', async () => {
|
||||
const { parseCommaSeparatedToArray } = await import('@/app/admin/settings/lib/helpers');
|
||||
expect(parseCommaSeparatedToArray('alpha, beta')).toBe('["alpha","beta"]');
|
||||
expect(parseCommaSeparatedToArray('')).toBe('[]');
|
||||
});
|
||||
|
||||
it('validates auth settings when no auth methods are enabled', async () => {
|
||||
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
const result = validateAuthSettings({
|
||||
...baseSettings,
|
||||
backendMode: 'audiobookshelf',
|
||||
hasLocalUsers: false,
|
||||
hasLocalAdmins: false,
|
||||
oidc: { ...baseSettings.oidc, enabled: false },
|
||||
registration: { enabled: false, requireAdminApproval: false },
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain('At least one authentication method must be enabled');
|
||||
});
|
||||
|
||||
it('prevents saving when manual registration is enabled but no admin users exist', async () => {
|
||||
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
const result = validateAuthSettings({
|
||||
...baseSettings,
|
||||
backendMode: 'audiobookshelf',
|
||||
hasLocalUsers: false,
|
||||
hasLocalAdmins: false,
|
||||
oidc: { ...baseSettings.oidc, enabled: false },
|
||||
registration: { enabled: true, requireAdminApproval: false },
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain('no local admin users exist');
|
||||
});
|
||||
|
||||
it('allows saving when manual registration is enabled and admin users exist', async () => {
|
||||
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
const result = validateAuthSettings({
|
||||
...baseSettings,
|
||||
backendMode: 'audiobookshelf',
|
||||
hasLocalUsers: true,
|
||||
hasLocalAdmins: true,
|
||||
oidc: { ...baseSettings.oidc, enabled: false },
|
||||
registration: { enabled: true, requireAdminApproval: false },
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('allows saving when OIDC is enabled even without local admin users', async () => {
|
||||
const { validateAuthSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
const result = validateAuthSettings({
|
||||
...baseSettings,
|
||||
backendMode: 'audiobookshelf',
|
||||
hasLocalUsers: false,
|
||||
hasLocalAdmins: false,
|
||||
oidc: { ...baseSettings.oidc, enabled: true },
|
||||
registration: { enabled: false, requireAdminApproval: false },
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns tab validation based on backend mode and changes', async () => {
|
||||
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
|
||||
const validated = {
|
||||
plex: true,
|
||||
audiobookshelf: false,
|
||||
oidc: false,
|
||||
registration: false,
|
||||
prowlarr: false,
|
||||
download: true,
|
||||
paths: true,
|
||||
};
|
||||
|
||||
expect(getTabValidation('library', baseSettings, baseSettings, validated)).toBe(true);
|
||||
expect(getTabValidation('download', baseSettings, baseSettings, validated)).toBe(true);
|
||||
|
||||
const changed = { ...baseSettings, prowlarr: { url: 'new', apiKey: 'key' } };
|
||||
expect(getTabValidation('prowlarr', changed, baseSettings, validated)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for auth tab when OIDC is disabled', async () => {
|
||||
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
|
||||
const validated = {
|
||||
plex: false,
|
||||
audiobookshelf: false,
|
||||
oidc: false,
|
||||
registration: false,
|
||||
prowlarr: false,
|
||||
download: false,
|
||||
paths: false,
|
||||
};
|
||||
|
||||
const settingsWithOidcDisabled = {
|
||||
...baseSettings,
|
||||
oidc: { ...baseSettings.oidc, enabled: false },
|
||||
};
|
||||
|
||||
expect(getTabValidation('auth', settingsWithOidcDisabled, baseSettings, validated)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for auth tab when OIDC is enabled but not validated', async () => {
|
||||
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
|
||||
const validated = {
|
||||
plex: false,
|
||||
audiobookshelf: false,
|
||||
oidc: false,
|
||||
registration: false,
|
||||
prowlarr: false,
|
||||
download: false,
|
||||
paths: false,
|
||||
};
|
||||
|
||||
expect(getTabValidation('auth', baseSettings, baseSettings, validated)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for auth tab when OIDC is enabled and validated', async () => {
|
||||
const { getTabValidation } = await import('@/app/admin/settings/lib/helpers');
|
||||
const validated = {
|
||||
plex: false,
|
||||
audiobookshelf: false,
|
||||
oidc: true,
|
||||
registration: false,
|
||||
prowlarr: false,
|
||||
download: false,
|
||||
paths: false,
|
||||
};
|
||||
|
||||
expect(getTabValidation('auth', baseSettings, baseSettings, validated)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns auth tabs for audiobookshelf mode', async () => {
|
||||
const { getTabs } = await import('@/app/admin/settings/lib/helpers');
|
||||
const absTabs = getTabs('audiobookshelf').map((tab) => tab.id);
|
||||
const plexTabs = getTabs('plex').map((tab) => tab.id);
|
||||
|
||||
expect(absTabs).toContain('auth');
|
||||
expect(plexTabs).not.toContain('auth');
|
||||
});
|
||||
|
||||
it('saves plex settings when library tab is active', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeOk())
|
||||
.mockResolvedValueOnce(makeOk());
|
||||
|
||||
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
await saveTabSettings('library', baseSettings, [], []);
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/admin/settings/audible',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/admin/settings/plex',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('saves audiobookshelf settings when library tab is active', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeOk())
|
||||
.mockResolvedValueOnce(makeOk());
|
||||
|
||||
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
await saveTabSettings('library', { ...baseSettings, backendMode: 'audiobookshelf' }, [], []);
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/admin/settings/audiobookshelf',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('saves auth settings with converted allowed lists', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeOk())
|
||||
.mockResolvedValueOnce(makeOk());
|
||||
|
||||
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
await saveTabSettings('auth', baseSettings, [], []);
|
||||
|
||||
const oidcBody = JSON.parse((fetchWithAuthMock.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(oidcBody.allowedEmails).toBe('["first@example.com","second@example.com"]');
|
||||
expect(oidcBody.allowedUsernames).toBe('["alpha","beta"]');
|
||||
});
|
||||
|
||||
it('saves OIDC settings even when disabled', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeOk())
|
||||
.mockResolvedValueOnce(makeOk());
|
||||
|
||||
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
const settingsWithOidcDisabled = {
|
||||
...baseSettings,
|
||||
oidc: { ...baseSettings.oidc, enabled: false },
|
||||
};
|
||||
await saveTabSettings('auth', settingsWithOidcDisabled, [], []);
|
||||
|
||||
// Verify OIDC endpoint is called even when disabled
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/admin/settings/oidc',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
|
||||
const oidcBody = JSON.parse((fetchWithAuthMock.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(oidcBody.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('saves prowlarr settings with enabled indexers and flag configs', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeOk())
|
||||
.mockResolvedValueOnce(makeOk());
|
||||
|
||||
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
await saveTabSettings(
|
||||
'prowlarr',
|
||||
baseSettings,
|
||||
[{ id: 1, name: 'Idx', protocol: 'torrent', priority: 1, seedingTimeMinutes: 10, rssEnabled: true, categories: [3030] }],
|
||||
[{ id: 'flag-1', name: 'Flag', weight: 1 }]
|
||||
);
|
||||
|
||||
const body = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
|
||||
expect(body.indexers[0].enabled).toBe(true);
|
||||
expect(body.flagConfigs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('saves download and paths settings', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue(makeOk());
|
||||
|
||||
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
await saveTabSettings('download', baseSettings, [], []);
|
||||
await saveTabSettings('paths', baseSettings, [], []);
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/admin/settings/download-client',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/admin/settings/paths',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for unsupported tab types', async () => {
|
||||
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
await expect(saveTabSettings('ebook', baseSettings, [], [])).rejects.toThrow('Unknown settings tab');
|
||||
});
|
||||
|
||||
it('throws when a save request fails', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeFail());
|
||||
|
||||
const { saveTabSettings } = await import('@/app/admin/settings/lib/helpers');
|
||||
await expect(saveTabSettings('library', baseSettings, [], [])).rejects.toThrow('Failed to save Audible region');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Component: Auth Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const makeResponse = (body: any, ok = true) => ({
|
||||
ok,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('useAuthSettings', () => {
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
onSuccess.mockReset();
|
||||
onError.mockReset();
|
||||
});
|
||||
|
||||
it('fetches pending users successfully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({ users: [{ id: 'u1', plexUsername: 'Pending' }] })
|
||||
);
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPendingUsers();
|
||||
});
|
||||
|
||||
expect(result.current.pendingUsers).toHaveLength(1);
|
||||
expect(result.current.loadingPendingUsers).toBe(false);
|
||||
});
|
||||
|
||||
it('tests OIDC configuration successfully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: true }));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testOIDCConnection('issuer', 'client', 'secret');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.oidcTestResult?.success).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalledWith('OIDC configuration is valid. You can now save.');
|
||||
});
|
||||
|
||||
it('surfaces OIDC validation errors', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: false, error: 'Bad issuer' }));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testOIDCConnection('issuer', 'client', 'secret');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.oidcTestResult?.message).toBe('Bad issuer');
|
||||
expect(onError).toHaveBeenCalledWith('Bad issuer');
|
||||
});
|
||||
|
||||
it('approves a pending user and refreshes the list', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ success: true, message: 'Approved' }))
|
||||
.mockResolvedValueOnce(makeResponse({ users: [] }));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.approveUser('u1', true);
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('Approved');
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/users/u1/approve', expect.any(Object));
|
||||
expect(result.current.pendingUsers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('surfaces approval failures', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: false, error: 'Nope' }));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.approveUser('u2', false);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Nope');
|
||||
});
|
||||
|
||||
it('handles pending user fetch errors gracefully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({}, false));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPendingUsers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loadingPendingUsers).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Component: BookDate Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const makeResponse = (body: any, ok = true) => ({
|
||||
ok,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('useBookDateSettings', () => {
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads BookDate config on mount', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({
|
||||
config: {
|
||||
provider: 'claude',
|
||||
model: 'claude-3',
|
||||
baseUrl: 'http://custom',
|
||||
isEnabled: false,
|
||||
isVerified: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(result.current.provider).toBe('claude'));
|
||||
|
||||
expect(result.current.model).toBe('claude-3');
|
||||
expect(result.current.baseUrl).toBe('http://custom');
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(result.current.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('validates missing API key for non-custom providers', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testConnection(onSuccess, onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please enter an API key');
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('validates missing base URL for custom providers', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setProvider('custom');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testConnection(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please enter a base URL for custom provider');
|
||||
});
|
||||
|
||||
it('tests connection with saved key and auto-selects the first model', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({
|
||||
config: {
|
||||
provider: 'openai',
|
||||
model: '',
|
||||
baseUrl: '',
|
||||
isEnabled: true,
|
||||
isVerified: true,
|
||||
},
|
||||
}))
|
||||
.mockResolvedValueOnce(makeResponse({
|
||||
models: [{ id: 'gpt-4' }, { id: 'gpt-3.5' }],
|
||||
}));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(result.current.configured).toBe(true));
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testConnection(onSuccess, onError);
|
||||
});
|
||||
|
||||
const requestBody = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
|
||||
expect(requestBody.useSavedKey).toBe(true);
|
||||
expect(requestBody.provider).toBe('openai');
|
||||
expect(result.current.models).toHaveLength(2);
|
||||
expect(result.current.model).toBe('gpt-4');
|
||||
expect(onSuccess).toHaveBeenCalledWith('Connection successful! Please select a model.');
|
||||
});
|
||||
|
||||
it('surfaces connection test errors', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({ error: 'Bad key' }, false));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setApiKey('key');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testConnection(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Bad key');
|
||||
});
|
||||
|
||||
it('validates missing model before saving', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please select a model');
|
||||
});
|
||||
|
||||
it('validates custom base URL before saving', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setProvider('custom');
|
||||
result.current.setModel('custom-model');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please enter a base URL for custom provider');
|
||||
});
|
||||
|
||||
it('validates API key for initial setup before saving', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setModel('gpt-4');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please enter an API key for initial setup');
|
||||
});
|
||||
|
||||
it('saves configuration and clears API key', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({}));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setModel('gpt-4');
|
||||
result.current.setApiKey('secret');
|
||||
result.current.setEnabled(false);
|
||||
});
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(onSuccess, vi.fn());
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('BookDate configuration saved successfully!');
|
||||
expect(result.current.configured).toBe(true);
|
||||
expect(result.current.apiKey).toBe('');
|
||||
});
|
||||
|
||||
it('surfaces save errors', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({ error: 'Save failed' }, false));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setModel('gpt-4');
|
||||
result.current.setApiKey('secret');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Save failed');
|
||||
});
|
||||
|
||||
it('skips clearing swipes when confirmation is canceled', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.clearSwipes(vi.fn(), vi.fn());
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears swipes after confirmation', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({}));
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.clearSwipes(onSuccess, vi.fn());
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('Swipe history cleared successfully!');
|
||||
});
|
||||
|
||||
it('reports errors when clearing swipes fails', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({ error: 'Clear failed' }, false));
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.clearSwipes(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Clear failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Component: Download Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const downloadClient = {
|
||||
type: 'qbittorrent',
|
||||
url: 'http://host',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
};
|
||||
|
||||
describe('useDownloadSettings', () => {
|
||||
const onChange = vi.fn();
|
||||
const onValidationChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
onChange.mockReset();
|
||||
onValidationChange.mockReset();
|
||||
});
|
||||
|
||||
it('updates fields and resets validation', async () => {
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('url', 'http://new');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...downloadClient, url: 'http://new' });
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('resets credentials when changing download client type', async () => {
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange('sabnzbd');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...downloadClient,
|
||||
type: 'sabnzbd',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('tests the download client connection successfully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, version: '1.2.3' }),
|
||||
});
|
||||
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.testConnection();
|
||||
expect(response?.success).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.testResult?.message).toContain('qbittorrent');
|
||||
});
|
||||
expect(onValidationChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('handles download client test failures', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: false, error: 'Bad credentials' }),
|
||||
});
|
||||
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.testConnection();
|
||||
expect(response?.success).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.testResult?.message).toBe('Bad credentials');
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('handles download client test exceptions', async () => {
|
||||
fetchWithAuthMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.testConnection();
|
||||
expect(response?.success).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.testResult?.message).toBe('network down');
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Component: Ebook Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const baseEbook = {
|
||||
enabled: true,
|
||||
preferredFormat: 'epub',
|
||||
baseUrl: 'https://annas-archive.li',
|
||||
flaresolverrUrl: 'http://flare',
|
||||
};
|
||||
|
||||
describe('useEbookSettings', () => {
|
||||
const onChange = vi.fn();
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const markAsSaved = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
onChange.mockReset();
|
||||
onSuccess.mockReset();
|
||||
onError.mockReset();
|
||||
markAsSaved.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('updates ebook settings and clears flaresolverr test results when URL changes', async () => {
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.updateEbook('flaresolverrUrl', 'http://new');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...baseEbook, flaresolverrUrl: 'http://new' });
|
||||
expect(result.current.flaresolverrTestResult).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an error when testing FlareSolverr without a URL', async () => {
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: { ...baseEbook, flaresolverrUrl: '' }, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testFlaresolverrConnection();
|
||||
});
|
||||
|
||||
expect(result.current.flaresolverrTestResult?.success).toBe(false);
|
||||
expect(result.current.flaresolverrTestResult?.message).toContain('Please enter a FlareSolverr URL');
|
||||
});
|
||||
|
||||
it('tests FlareSolverr connection successfully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'OK' }),
|
||||
});
|
||||
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testFlaresolverrConnection();
|
||||
});
|
||||
|
||||
expect(result.current.flaresolverrTestResult?.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles FlareSolverr test failures', async () => {
|
||||
fetchWithAuthMock.mockRejectedValueOnce(new Error('flare down'));
|
||||
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testFlaresolverrConnection();
|
||||
});
|
||||
|
||||
expect(result.current.flaresolverrTestResult?.message).toBe('flare down');
|
||||
});
|
||||
|
||||
it('saves ebook settings and clears success banner after delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
fetchWithAuthMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
|
||||
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSettings();
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('E-book sidecar settings saved successfully!');
|
||||
expect(markAsSaved).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('surfaces save errors', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({ ok: false, json: async () => ({}) });
|
||||
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSettings();
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Failed to save e-book settings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Component: Library Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const makeResponse = (body: any) => ({
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('useLibrarySettings', () => {
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onValidationChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
onSuccess.mockReset();
|
||||
onError.mockReset();
|
||||
onValidationChange.mockReset();
|
||||
});
|
||||
|
||||
it('tests Plex connection successfully and stores libraries', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
success: true,
|
||||
serverName: 'Plex Server',
|
||||
libraries: [{ id: 'lib-1', title: 'Main' }],
|
||||
})
|
||||
);
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testPlexConnection('http://plex', 'token');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.plexLibraries).toHaveLength(1);
|
||||
expect(result.current.plexTestResult?.success).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalledWith('Connected to Plex Server. You can now save.');
|
||||
expect(onValidationChange).toHaveBeenCalledWith('plex', true);
|
||||
});
|
||||
|
||||
it('surfaces Plex connection errors', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
success: false,
|
||||
error: 'Bad token',
|
||||
})
|
||||
);
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testPlexConnection('http://plex', 'token');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.plexTestResult?.message).toBe('Bad token');
|
||||
expect(onError).toHaveBeenCalledWith('Bad token');
|
||||
expect(onValidationChange).toHaveBeenCalledWith('plex', false);
|
||||
});
|
||||
|
||||
it('tests Audiobookshelf connection successfully and stores libraries', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
success: true,
|
||||
libraries: [{ id: 'abs-1', name: 'ABS Main' }],
|
||||
})
|
||||
);
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testABSConnection('http://abs', 'token');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.absLibraries).toHaveLength(1);
|
||||
expect(result.current.absTestResult?.success).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalledWith('Connected to Audiobookshelf. You can now save.');
|
||||
expect(onValidationChange).toHaveBeenCalledWith('audiobookshelf', true);
|
||||
});
|
||||
|
||||
it('surfaces Audiobookshelf connection failures', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
success: false,
|
||||
error: 'ABS down',
|
||||
})
|
||||
);
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testABSConnection('http://abs', 'token');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.absTestResult?.message).toBe('ABS down');
|
||||
expect(onError).toHaveBeenCalledWith('ABS down');
|
||||
expect(onValidationChange).toHaveBeenCalledWith('audiobookshelf', false);
|
||||
});
|
||||
|
||||
it('handles exceptions while testing connections', async () => {
|
||||
fetchWithAuthMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testPlexConnection('http://plex', 'token');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plexTestResult?.message).toBe('network down');
|
||||
});
|
||||
expect(onValidationChange).toHaveBeenCalledWith('plex', false);
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,53 @@ describe('BookDatePage', () => {
|
||||
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('loads recommendations after completing onboarding', 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 });
|
||||
}
|
||||
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 />);
|
||||
|
||||
await screen.findByText('Welcome to BookDate!');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Finish Onboarding' }));
|
||||
|
||||
expect(await screen.findByTestId('card-count')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('loads recommendations when onboarding status check 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({ error: 'fail' }, false);
|
||||
}
|
||||
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');
|
||||
});
|
||||
|
||||
it('renders an error state when recommendations fetch fails', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
@@ -147,6 +194,31 @@ describe('BookDatePage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to settings from the error state', 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 />);
|
||||
|
||||
await screen.findByText(/Could not load recommendations/);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
|
||||
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/settings');
|
||||
});
|
||||
|
||||
it('shows empty state and triggers recommendation generation', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ describe('LoginPage', () => {
|
||||
resetMockRouter();
|
||||
resetMockAuthState();
|
||||
localStorage.clear();
|
||||
document.cookie.split(';').forEach((cookie) => {
|
||||
const name = cookie.split('=')[0]?.trim();
|
||||
if (name) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
});
|
||||
setMockSearchParams('');
|
||||
window.innerWidth = 1024;
|
||||
vi.resetModules();
|
||||
@@ -520,4 +526,120 @@ describe('LoginPage', () => {
|
||||
|
||||
expect(await screen.findByText('Access Denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to cookies when mobile auth has no hash', async () => {
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
setMockSearchParams('auth=success&redirect=/requests');
|
||||
|
||||
const userData = { id: 'user-10', username: 'cookie-user', role: 'user' };
|
||||
document.cookie = 'accessToken=cookie-access';
|
||||
document.cookie = 'refreshToken=cookie-refresh';
|
||||
document.cookie = `userData=${encodeURIComponent(JSON.stringify(userData))}`;
|
||||
|
||||
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(userData, 'cookie-access');
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/requests');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBe('cookie-access');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('cookie-refresh');
|
||||
});
|
||||
|
||||
it('shows an error when cookie auth payload is invalid', async () => {
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
setMockSearchParams('auth=success');
|
||||
document.cookie = 'accessToken=cookie-access';
|
||||
document.cookie = 'userData=not-json';
|
||||
|
||||
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('Login failed. Please try again.')).toBeInTheDocument();
|
||||
expect(setAuthDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error when cookie auth data is missing', async () => {
|
||||
setMockSearchParams('auth=success');
|
||||
|
||||
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('Authentication failed. Please try again.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to Plex OAuth on mobile without opening a popup', async () => {
|
||||
window.innerWidth = 500;
|
||||
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: 321, authUrl: 'http://plex/mobile' });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const openMock = vi.fn();
|
||||
vi.stubGlobal('open', openMock);
|
||||
|
||||
const originalLocation = window.location;
|
||||
delete (window as any).location;
|
||||
(window as any).location = {
|
||||
...originalLocation,
|
||||
href: 'http://localhost/login',
|
||||
hash: '',
|
||||
pathname: '/login',
|
||||
search: '',
|
||||
};
|
||||
|
||||
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(openMock).not.toHaveBeenCalled();
|
||||
expect(loginMock).not.toHaveBeenCalled();
|
||||
expect(window.location.href).toBe('http://plex/mobile');
|
||||
});
|
||||
|
||||
(window as any).location = originalLocation;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,12 @@ const mockSetupModules = () => {
|
||||
<button type="button" onClick={() => onChange('oidc')}>
|
||||
Choose OIDC
|
||||
</button>
|
||||
<button type="button" onClick={() => onChange('manual')}>
|
||||
Choose Manual
|
||||
</button>
|
||||
<button type="button" onClick={() => onChange('both')}>
|
||||
Choose Both
|
||||
</button>
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
@@ -102,10 +108,28 @@ const mockSetupModules = () => {
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/OIDCConfigStep.tsx'), () => ({
|
||||
OIDCConfigStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
OIDCConfigStep: ({
|
||||
onNext,
|
||||
onUpdate,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onUpdate: (field: string, value: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onUpdate('oidcAccessControlMethod', 'allowed_list');
|
||||
onUpdate('oidcAllowedEmails', 'user1@example.com, user2@example.com');
|
||||
onUpdate('oidcAllowedUsernames', 'john, jane');
|
||||
}}
|
||||
>
|
||||
Set Allowed Lists
|
||||
</button>
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -260,4 +284,99 @@ describe('SetupWizard', () => {
|
||||
expect(requestBody.oidc).toBeDefined();
|
||||
expect(requestBody.admin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('completes setup in manual auth mode and includes registration settings', 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 />);
|
||||
|
||||
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 Manual' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
for (let i = 0; i < 6; 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('audiobookshelf');
|
||||
expect(requestBody.authMethod).toBe('manual');
|
||||
expect(requestBody.registration).toEqual({
|
||||
enabled: true,
|
||||
require_admin_approval: true,
|
||||
});
|
||||
expect(requestBody.admin).toBeDefined();
|
||||
expect(requestBody.oidc).toBeUndefined();
|
||||
expect(requestBody.bookdate).toBeNull();
|
||||
});
|
||||
|
||||
it('serializes OIDC allowed lists as JSON arrays', 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);
|
||||
|
||||
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' }));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Set Allowed Lists' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
||||
}
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(requestBody.oidc.allowed_emails).toBe(
|
||||
JSON.stringify(['user1@example.com', 'user2@example.com'])
|
||||
);
|
||||
expect(requestBody.oidc.allowed_usernames).toBe(JSON.stringify(['john', 'jane']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,4 +204,86 @@ describe('InitializingPage', () => {
|
||||
expect(screen.getAllByText(/Job failed to complete/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('marks jobs as error when scheduled job configuration is missing', 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' }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url === '/api/admin/job-status/run-1') {
|
||||
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(screen.getAllByText(/Job configuration not found/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('navigates to homepage when setup is complete', 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();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await screen.getByRole('button', { name: 'Go to Homepage' }).click();
|
||||
});
|
||||
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user