mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -40,7 +40,7 @@ describe('Admin Prowlarr indexers route', () => {
|
||||
|
||||
it('returns indexers with saved config', async () => {
|
||||
prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent' }]);
|
||||
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 5, seedingTimeMinutes: 10 }]));
|
||||
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 5, seedingTimeMinutes: 10 }]));
|
||||
configServiceMock.get.mockResolvedValueOnce('[]');
|
||||
|
||||
const { GET } = await import('@/app/api/admin/settings/prowlarr/indexers/route');
|
||||
@@ -53,7 +53,7 @@ describe('Admin Prowlarr indexers route', () => {
|
||||
|
||||
it('saves indexer configuration', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
indexers: [{ id: 1, name: 'Indexer', enabled: true, priority: 10, seedingTimeMinutes: 0 }],
|
||||
indexers: [{ id: 1, name: 'Indexer', protocol: 'torrent', enabled: true, priority: 10, seedingTimeMinutes: 0 }],
|
||||
flagConfigs: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Audiobooks search torrents route', () => {
|
||||
it('returns ranked results with rank order', async () => {
|
||||
authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' });
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 10 }]))
|
||||
.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10 }]))
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
groupIndexersMock.mockReturnValue([{ categories: [1], indexerIds: [1] }]);
|
||||
|
||||
@@ -159,6 +159,48 @@ describe('Auth misc routes', () => {
|
||||
expect(payload.registrationEnabled).toBe(true);
|
||||
expect(payload.oidcProviderName).toBe('MyOIDC');
|
||||
});
|
||||
|
||||
it('shows local provider when registration is enabled even without existing users', async () => {
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce('audiobookshelf') // backend mode
|
||||
.mockResolvedValueOnce(null) // indexer type
|
||||
.mockResolvedValueOnce(null) // prowlarr url
|
||||
.mockResolvedValueOnce('false') // oidc enabled
|
||||
.mockResolvedValueOnce('true') // registration enabled
|
||||
.mockResolvedValueOnce('SSO'); // oidc provider name
|
||||
|
||||
prismaMock.user.count.mockResolvedValueOnce(0); // No local users exist
|
||||
|
||||
const { GET } = await import('@/app/api/auth/providers/route');
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.backendMode).toBe('audiobookshelf');
|
||||
expect(payload.providers).toContain('local'); // Should include 'local' for registration
|
||||
expect(payload.registrationEnabled).toBe(true);
|
||||
expect(payload.hasLocalUsers).toBe(false);
|
||||
});
|
||||
|
||||
it('does not show local provider when registration is disabled and no users exist', async () => {
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce('audiobookshelf') // backend mode
|
||||
.mockResolvedValueOnce(null) // indexer type
|
||||
.mockResolvedValueOnce(null) // prowlarr url
|
||||
.mockResolvedValueOnce('false') // oidc enabled
|
||||
.mockResolvedValueOnce('false') // registration disabled
|
||||
.mockResolvedValueOnce('SSO'); // oidc provider name
|
||||
|
||||
prismaMock.user.count.mockResolvedValueOnce(0); // No local users exist
|
||||
|
||||
const { GET } = await import('@/app/api/auth/providers/route');
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.backendMode).toBe('audiobookshelf');
|
||||
expect(payload.providers).not.toContain('local'); // Should NOT include 'local'
|
||||
expect(payload.registrationEnabled).toBe(false);
|
||||
expect(payload.hasLocalUsers).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,6 +342,307 @@ describe('BookDate helpers', () => {
|
||||
expect(plexMock.getServerAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns cached books when rating enrichment user lookup fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-1' })
|
||||
.mockResolvedValueOnce(null);
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: '5',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns cached books when server access token is unavailable', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
libraryId: 'plex-lib',
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-1' })
|
||||
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
encryptionMock.decrypt.mockReturnValue('user-token');
|
||||
plexMock.getServerAccessToken.mockResolvedValue(null);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
expect(plexMock.getServerAccessToken).toHaveBeenCalledWith('machine', 'user-token');
|
||||
});
|
||||
|
||||
it('falls back to cached books when user ratings fetch is unauthorized', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
libraryId: 'plex-lib',
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-1' })
|
||||
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
encryptionMock.decrypt.mockReturnValue('user-token');
|
||||
plexMock.getServerAccessToken.mockResolvedValue('server-token');
|
||||
const unauthorizedError = new Error('Unauthorized');
|
||||
(unauthorizedError as Error & { response?: { status: number } }).response = { status: 401 };
|
||||
plexMock.getLibraryContent.mockRejectedValue(unauthorizedError);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to cached books when user ratings fetch fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
libraryId: 'plex-lib',
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-1' })
|
||||
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
encryptionMock.decrypt.mockReturnValue('user-token');
|
||||
plexMock.getServerAccessToken.mockResolvedValue('server-token');
|
||||
plexMock.getLibraryContent.mockRejectedValue(new Error('fetch failed'));
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns cached books when enrichment throws an error', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-1' })
|
||||
.mockRejectedValueOnce(new Error('db down'));
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: '6',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to full library when favorites are empty', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib-1');
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ bookDateFavoriteBookIds: null })
|
||||
.mockResolvedValueOnce({ plexId: 'abs-1' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'favorites');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty favorites when audiobookshelf library id is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
bookDateFavoriteBookIds: JSON.stringify(['book-1']),
|
||||
});
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'favorites');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty favorites when plex library id is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
bookDateFavoriteBookIds: JSON.stringify(['book-1']),
|
||||
});
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'favorites');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns audiobookshelf favorites without ratings', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib-1');
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
bookDateFavoriteBookIds: JSON.stringify(['book-1']),
|
||||
});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Favorite',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: '8',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'favorites');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Favorite',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns plex favorites with cached ratings for local admins', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ bookDateFavoriteBookIds: JSON.stringify(['book-1']) })
|
||||
.mockResolvedValueOnce({ authToken: null, plexId: 'local-1', role: 'admin' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Favorite',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: '7',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'favorites');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Favorite',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty list when library query fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib-1');
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({ plexId: 'abs-1' });
|
||||
prismaMock.plexLibrary.findMany.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds recent swipe history from prioritized swipes', async () => {
|
||||
const now = new Date();
|
||||
const older = new Date(now.getTime() - 1000);
|
||||
@@ -396,6 +697,15 @@ describe('BookDate helpers', () => {
|
||||
expect(prismaMock.bookDateSwipe.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns empty swipes when swipe history lookup fails', async () => {
|
||||
prismaMock.bookDateSwipe.findMany.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const { getUserRecentSwipes } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserRecentSwipes('user-1', 5);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds AI prompt with mapped swipe actions', async () => {
|
||||
const now = new Date();
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
@@ -458,6 +768,33 @@ describe('BookDate helpers', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds favorites instruction to the AI prompt when using favorites scope', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib-1');
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
bookDateFavoriteBookIds: JSON.stringify(['book-1']),
|
||||
});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Favorite',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
prismaMock.bookDateSwipe.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const { buildAIPrompt } = await import('@/lib/bookdate/helpers');
|
||||
const prompt = await buildAIPrompt('user-1', { libraryScope: 'favorites', customPrompt: null });
|
||||
const parsed = JSON.parse(prompt);
|
||||
|
||||
expect(parsed.instructions).toContain('handpicked');
|
||||
});
|
||||
|
||||
it('returns cached Audnexus matches without fetching Audible', async () => {
|
||||
prismaMock.audibleCache.findFirst.mockResolvedValue({
|
||||
asin: 'ASIN1',
|
||||
@@ -540,6 +877,14 @@ describe('BookDate helpers', () => {
|
||||
await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when library matching throws an error', async () => {
|
||||
const { isInLibrary } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
findPlexMatchMock.mockRejectedValueOnce(new Error('match failed'));
|
||||
|
||||
await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('checks existing requests and swipes', async () => {
|
||||
const { isAlreadyRequested, isAlreadySwiped } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
@@ -560,6 +905,16 @@ describe('BookDate helpers', () => {
|
||||
await expect(callAI('invalid', 'model', 'key', '{}')).rejects.toThrow('Invalid provider');
|
||||
});
|
||||
|
||||
it('throws when decrypting API keys fails for non-custom providers', async () => {
|
||||
encryptionMock.decrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
await expect(callAI('openai', 'model', 'enc-key', '{}')).rejects.toThrow('decrypt failed');
|
||||
});
|
||||
|
||||
it('requires a base URL for custom providers', async () => {
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
@@ -587,6 +942,22 @@ describe('BookDate helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when OpenAI responds with an error', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: vi.fn().mockResolvedValue('Unauthorized'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockReturnValue('api-key');
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
await expect(callAI('openai', 'model', 'enc-key', '{}')).rejects.toThrow(
|
||||
'OpenAI API error: 401 Unauthorized'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls Claude and strips markdown from JSON', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -608,6 +979,22 @@ describe('BookDate helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when Claude responds with an error', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: vi.fn().mockResolvedValue('Server down'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockReturnValue('api-key');
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
await expect(callAI('claude', 'model', 'enc-key', '{}')).rejects.toThrow(
|
||||
'Claude API error: 500 Server down'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls custom provider and parses direct JSON', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -628,6 +1015,56 @@ describe('BookDate helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when custom providers return non-schema errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: vi.fn().mockResolvedValue('Boom'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockReturnValue('api-key');
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
await expect(callAI('custom', 'model', 'enc-key', '{}', 'http://custom')).rejects.toThrow(
|
||||
'Custom provider API error: 500 Boom'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when custom provider retry fails', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: vi.fn().mockResolvedValue('response_format unsupported'),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: vi.fn().mockResolvedValue('still bad'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockReturnValue('api-key');
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
await expect(callAI('custom', 'model', 'enc-key', '{}', 'http://custom')).rejects.toThrow(
|
||||
'Custom provider API error: 500 still bad'
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps custom provider fetch failures', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockReturnValue('api-key');
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
await expect(callAI('custom', 'model', 'enc-key', '{}', 'http://custom')).rejects.toThrow(
|
||||
'Custom provider error: network down'
|
||||
);
|
||||
});
|
||||
|
||||
it('retries custom providers without structured output', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@@ -652,4 +1089,14 @@ describe('BookDate helpers', () => {
|
||||
expect(result.recommendations).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns null when Audnexus matching throws', async () => {
|
||||
prismaMock.audibleCache.findFirst.mockResolvedValue(null);
|
||||
audibleState.instance.search.mockRejectedValue(new Error('Audible down'));
|
||||
|
||||
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
||||
const result = await matchToAudnexus('Title', 'Author');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('IndexersTab - Auto-load Indexers on Tab Activation', () => {
|
||||
{
|
||||
id: 1,
|
||||
name: 'AudioBook Bay',
|
||||
protocol: 'torrent',
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 4320,
|
||||
rssEnabled: true,
|
||||
@@ -132,8 +133,9 @@ describe('IndexersTab - Auto-load Indexers on Tab Activation', () => {
|
||||
{
|
||||
id: 2,
|
||||
name: 'MyAnonaMouse',
|
||||
protocol: 'usenet',
|
||||
priority: 15,
|
||||
seedingTimeMinutes: 10080,
|
||||
removeAfterProcessing: true,
|
||||
rssEnabled: false,
|
||||
categories: [3030],
|
||||
},
|
||||
|
||||
@@ -10,12 +10,14 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const swipeHandlers: {
|
||||
onSwipeStart?: () => void;
|
||||
onSwiping?: (eventData: { deltaX: number; deltaY: number }) => void;
|
||||
onSwiped?: (eventData: { deltaX: number; deltaY: number }) => void;
|
||||
} = {};
|
||||
|
||||
vi.mock('react-swipeable', () => ({
|
||||
useSwipeable: (handlers: any) => {
|
||||
swipeHandlers.onSwipeStart = handlers.onSwipeStart;
|
||||
swipeHandlers.onSwiping = handlers.onSwiping;
|
||||
swipeHandlers.onSwiped = handlers.onSwiped;
|
||||
return {};
|
||||
@@ -33,6 +35,7 @@ const recommendation = {
|
||||
|
||||
describe('RecommendationCard', () => {
|
||||
beforeEach(() => {
|
||||
swipeHandlers.onSwipeStart = undefined;
|
||||
swipeHandlers.onSwiping = undefined;
|
||||
swipeHandlers.onSwiped = undefined;
|
||||
});
|
||||
@@ -87,6 +90,7 @@ describe('RecommendationCard', () => {
|
||||
render(<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} />);
|
||||
|
||||
act(() => {
|
||||
swipeHandlers.onSwipeStart?.();
|
||||
swipeHandlers.onSwiping?.({ deltaX: -80, deltaY: 0 });
|
||||
});
|
||||
|
||||
|
||||
@@ -223,4 +223,135 @@ describe('Header', () => {
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'BookDate' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens change password modal and closes it', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, {
|
||||
auth: {
|
||||
user: {
|
||||
id: 'user-4',
|
||||
plexId: 'plex-4',
|
||||
username: 'local-admin',
|
||||
role: 'admin',
|
||||
authProvider: 'local',
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const userButton = screen.getByText('local-admin').closest('button');
|
||||
expect(userButton).not.toBeNull();
|
||||
await userEvent.click(userButton as HTMLButtonElement);
|
||||
|
||||
const changePasswordButton = await screen.findByText('Change Password');
|
||||
await userEvent.click(changePasswordButton);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Change Password' })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: 'Change Password' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the user menu when profile is clicked', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, {
|
||||
auth: {
|
||||
user: {
|
||||
id: 'user-5',
|
||||
plexId: 'plex-5',
|
||||
username: 'reader',
|
||||
role: 'user',
|
||||
authProvider: 'local',
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const userButton = screen.getByText('reader').closest('button');
|
||||
expect(userButton).not.toBeNull();
|
||||
await userEvent.click(userButton as HTMLButtonElement);
|
||||
|
||||
const profileLink = await screen.findByRole('link', { name: 'Profile' });
|
||||
await userEvent.click(profileLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Logout')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('logs errors when Plex login fails', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('login failed'));
|
||||
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const openMock = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, { auth: { user: null, isLoading: false } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /login with plex/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(errorMock).toHaveBeenCalledWith('Login failed:', expect.any(Error));
|
||||
});
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the mobile menu when BookDate is selected', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
|
||||
if (input === '/api/version') {
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
}
|
||||
if (input === '/api/bookdate/config') {
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
config: { isVerified: true, isEnabled: true },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, {
|
||||
auth: {
|
||||
user: {
|
||||
id: 'user-6',
|
||||
plexId: 'plex-6',
|
||||
username: 'reader',
|
||||
role: 'user',
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const initialBookDateCount = (await screen.findAllByRole('link', { name: 'BookDate' })).length;
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Toggle menu' }));
|
||||
|
||||
const bookDateLinks = await screen.findAllByRole('link', { name: 'BookDate' });
|
||||
expect(bookDateLinks).toHaveLength(initialBookDateCount + 1);
|
||||
|
||||
await userEvent.click(bookDateLinks[bookDateLinks.length - 1]);
|
||||
|
||||
await waitFor(async () => {
|
||||
const remainingLinks = await screen.findAllByRole('link', { name: 'BookDate' });
|
||||
expect(remainingLinks).toHaveLength(initialBookDateCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Component: Pagination 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 { Pagination } from '@/components/ui/Pagination';
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('renders nothing when there is only one page', () => {
|
||||
const { container } = render(<Pagination currentPage={1} totalPages={1} onPageChange={vi.fn()} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders ellipses for large page ranges', () => {
|
||||
render(<Pagination currentPage={5} totalPages={10} onPageChange={vi.fn()} />);
|
||||
const ellipses = screen.getAllByText('...');
|
||||
expect(ellipses.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onPageChange for navigation controls', () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(<Pagination currentPage={2} totalPages={5} onPageChange={onPageChange} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Next page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Page 4'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(4);
|
||||
});
|
||||
});
|
||||
@@ -192,7 +192,10 @@ describe('AudibleService', () => {
|
||||
|
||||
it('returns empty search results on failures', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockRejectedValue(new Error('nope'));
|
||||
// Use 404 error which is not retryable
|
||||
const error: any = new Error('Not Found');
|
||||
error.response = { status: 404 };
|
||||
clientMock.get.mockRejectedValue(error);
|
||||
|
||||
const service = new AudibleService();
|
||||
const result = await service.search('oops', 1);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const useAuthMock = vi.hoisted(() => vi.fn());
|
||||
@@ -37,6 +37,16 @@ const renderHookValue = <T,>(hook: () => T) => {
|
||||
return value!;
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -66,6 +76,21 @@ describe('useRequests hooks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('skips request list endpoints when unauthenticated', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: null });
|
||||
useSWRMock.mockReturnValue({ data: null, error: null, isLoading: false });
|
||||
|
||||
const { useRequests } = await import('@/lib/hooks/useRequests');
|
||||
|
||||
renderHookValue(() => useRequests());
|
||||
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
null,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ refreshInterval: 5000 })
|
||||
);
|
||||
});
|
||||
|
||||
it('builds request detail endpoints when authenticated', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
useSWRMock.mockReturnValue({ data: { request: { id: 'req-1' } }, error: null, isLoading: false });
|
||||
@@ -100,6 +125,37 @@ describe('useRequests hooks', () => {
|
||||
expect(mutateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds skipAutoSearch query params when creating requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-10' } }));
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useCreateRequest());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createRequest(
|
||||
{ asin: 'a10', title: 'Book', author: 'Author' } as any,
|
||||
{ skipAutoSearch: true }
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/requests?skipAutoSearch=true',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when creating a request without authentication', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: null });
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useCreateRequest());
|
||||
|
||||
await expect(
|
||||
result.current.createRequest({ asin: 'a1', title: 'Book', author: 'Author' } as any)
|
||||
).rejects.toThrow('Not authenticated');
|
||||
});
|
||||
|
||||
it('surfaces specific create request errors', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'AlreadyAvailable' }, false));
|
||||
@@ -114,6 +170,42 @@ describe('useRequests hooks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces being processed errors when creating requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'BeingProcessed' }, false));
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useCreateRequest());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.createRequest({ asin: 'a2', title: 'Book', author: 'Author' } as any)
|
||||
).rejects.toThrow('being processed');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toContain('being processed');
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces API error messages when creating requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Backend refused' }, false));
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useCreateRequest());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.createRequest({ asin: 'a3', title: 'Book', author: 'Author' } as any)
|
||||
).rejects.toThrow('Backend refused');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Backend refused');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels requests via the API', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-2' } }));
|
||||
@@ -148,6 +240,22 @@ describe('useRequests hooks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('captures API errors when triggering manual search', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Manual search failed' }, false));
|
||||
|
||||
const { useManualSearch } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useManualSearch());
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.triggerManualSearch('req-3')).rejects.toThrow('Manual search failed');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Manual search failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('searches torrents interactively for a request', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ results: [{ guid: 't1' }] }));
|
||||
@@ -166,6 +274,22 @@ describe('useRequests hooks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports interactive search errors', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Search failed' }, false));
|
||||
|
||||
const { useInteractiveSearch } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useInteractiveSearch());
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.searchTorrents('req-4')).rejects.toThrow('Search failed');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Search failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('selects torrents for existing requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-5' } }));
|
||||
@@ -217,4 +341,46 @@ describe('useRequests hooks', () => {
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces being processed errors when requesting with torrents', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'BeingProcessed' }, false));
|
||||
|
||||
const { useRequestWithTorrent } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useRequestWithTorrent());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.requestWithTorrent(
|
||||
{ asin: 'a4', title: 'Book', author: 'Author' } as any,
|
||||
{ title: 'Torrent' }
|
||||
)
|
||||
).rejects.toThrow('being processed');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toContain('being processed');
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces already available errors when requesting with torrents', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'AlreadyAvailable' }, false));
|
||||
|
||||
const { useRequestWithTorrent } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useRequestWithTorrent());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.requestWithTorrent(
|
||||
{ asin: 'a5', title: 'Book', author: 'Author' } as any,
|
||||
{ title: 'Torrent' }
|
||||
)
|
||||
).rejects.toThrow('already in your Plex library');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toContain('already in your Plex library');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* Component: Match Library Processor Tests
|
||||
* Documentation: documentation/phase3/README.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const libraryServiceMock = vi.hoisted(() => ({ searchItems: vi.fn() }));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const compareTwoStringsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library', () => ({
|
||||
getLibraryService: async () => libraryServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('string-similarity', () => ({
|
||||
compareTwoStrings: compareTwoStringsMock,
|
||||
}));
|
||||
|
||||
describe('processMatchPlex', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('completes request when no library results are found', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-1',
|
||||
audiobookId: 'ab-1',
|
||||
title: 'Missing Title',
|
||||
author: 'Author',
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-1' },
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.audiobook.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates audiobook and request when a high-score match is found (plex)', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-1',
|
||||
externalId: 'guid-1',
|
||||
title: 'Best Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.95);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-2',
|
||||
audiobookId: 'ab-2',
|
||||
title: 'Best Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'ab-2' },
|
||||
data: expect.objectContaining({ plexGuid: 'guid-1' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-2' },
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses audiobookshelf IDs when backend mode is audiobookshelf', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-abs',
|
||||
externalId: 'abs-1',
|
||||
title: 'Shelf Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.9);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-3',
|
||||
audiobookId: 'ab-3',
|
||||
title: 'Shelf Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-3',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ absItemId: 'abs-1' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('completes request without match when score is too low', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-low',
|
||||
externalId: 'guid-low',
|
||||
title: 'Low Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.1);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-4',
|
||||
audiobookId: 'ab-4',
|
||||
title: 'Low Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-4',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(prismaMock.audiobook.update).not.toHaveBeenCalled();
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request completed with error when matching fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-5',
|
||||
audiobookId: 'ab-5',
|
||||
title: 'Error Title',
|
||||
author: 'Author',
|
||||
jobId: 'job-5',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -253,6 +253,41 @@ describe('processMonitorDownload', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('converts SABnzbd progress from 0.0-1.0 to 0-100 percentage', async () => {
|
||||
sabMock.getNZB.mockResolvedValue({
|
||||
nzbId: 'nzb-3',
|
||||
size: 1000000000, // 1GB
|
||||
progress: 0.35, // 35% in decimal format (0.0-1.0)
|
||||
status: 'downloading',
|
||||
downloadSpeed: 5000000, // 5MB/s
|
||||
timeLeft: 130,
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
requestId: 'req-8',
|
||||
downloadHistoryId: 'dh-8',
|
||||
downloadClientId: 'nzb-3',
|
||||
downloadClient: 'sabnzbd',
|
||||
jobId: 'job-8',
|
||||
});
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(result.progress).toBe(35); // Should be converted to 35 (not 0.35)
|
||||
|
||||
// Verify database was updated with correct percentage (0-100, not 0.0-1.0)
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-8' },
|
||||
data: expect.objectContaining({
|
||||
progress: 35, // Should be 35, not 0.35
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -339,6 +339,55 @@ describe('processOrganizeFiles', () => {
|
||||
expect.stringContaining('File organization failed')
|
||||
);
|
||||
});
|
||||
|
||||
it('generates and stores filesHash after successful organization', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||
id: 'a-hash-1',
|
||||
title: 'Book With Hash',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
coverArtUrl: null,
|
||||
audibleAsin: 'ASIN-HASH',
|
||||
});
|
||||
organizerMock.organize.mockResolvedValue({
|
||||
success: true,
|
||||
targetPath: '/media/Author/Book',
|
||||
filesMovedCount: 3,
|
||||
errors: [],
|
||||
audioFiles: [
|
||||
'/media/Author/Book/Chapter 01.mp3',
|
||||
'/media/Author/Book/Chapter 02.mp3',
|
||||
'/media/Author/Book/Chapter 03.mp3',
|
||||
],
|
||||
});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('false');
|
||||
|
||||
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
|
||||
const result = await processOrganizeFiles({
|
||||
requestId: 'req-hash-1',
|
||||
audiobookId: 'a-hash-1',
|
||||
downloadPath: '/downloads/book',
|
||||
jobId: 'job-hash-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify filesHash was included in the audiobook update
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'a-hash-1' },
|
||||
data: expect.objectContaining({
|
||||
filePath: '/media/Author/Book',
|
||||
filesHash: expect.stringMatching(/^[a-f0-9]{64}$/), // SHA256 hash format
|
||||
status: 'completed',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
|
||||
vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
||||
triggerABSItemMatch: vi.fn(),
|
||||
getABSItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/thumbnail-cache.service', () => ({
|
||||
@@ -124,7 +125,7 @@ describe('processPlexRecentlyAddedCheck', () => {
|
||||
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches requests and triggers ABS metadata match for audiobookshelf', async () => {
|
||||
it('matches requests without re-triggering ABS metadata match for audiobookshelf', async () => {
|
||||
const matcher = await import('@/lib/utils/audiobook-matcher');
|
||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||
|
||||
@@ -150,6 +151,7 @@ describe('processPlexRecentlyAddedCheck', () => {
|
||||
externalId: 'abs-item-1',
|
||||
title: 'New ABS Item',
|
||||
author: 'Author A',
|
||||
asin: 'ASIN-ABS', // Item already has ASIN from ABS
|
||||
addedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -196,7 +198,8 @@ describe('processPlexRecentlyAddedCheck', () => {
|
||||
data: expect.objectContaining({ status: 'available' }),
|
||||
})
|
||||
);
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN-ABS');
|
||||
// Should NOT trigger metadata match - items already have metadata from ABS
|
||||
expect(absApi.triggerABSItemMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
|
||||
vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
||||
triggerABSItemMatch: vi.fn(),
|
||||
getABSItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
@@ -240,7 +241,7 @@ describe('processScanPlex', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('matches audiobookshelf requests and triggers metadata match', async () => {
|
||||
it('matches audiobookshelf requests without re-triggering metadata match', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
@@ -294,7 +295,134 @@ describe('processScanPlex', () => {
|
||||
data: expect.objectContaining({ absItemId: 'abs-item-1' }),
|
||||
})
|
||||
);
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN123');
|
||||
// Should NOT trigger metadata match - items with ASIN already have correct metadata
|
||||
expect(absApi.triggerABSItemMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses file hash matching for ABS items without ASIN', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://abs',
|
||||
authToken: 'token',
|
||||
backendMode: 'audiobookshelf',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
// Return an item without ASIN
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-hash-1',
|
||||
externalId: 'abs-hash-1',
|
||||
title: 'Book Without ASIN',
|
||||
author: 'Author',
|
||||
asin: null, // No ASIN yet
|
||||
addedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.plexLibrary.findFirst.mockResolvedValue(null);
|
||||
prismaMock.plexLibrary.create.mockResolvedValue({});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
prismaMock.audiobook.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
|
||||
// Mock getABSItem to return item with audio files
|
||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||
(absApi.getABSItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'abs-hash-1',
|
||||
media: {
|
||||
audioFiles: [
|
||||
{ metadata: { filename: 'Chapter 01.mp3' } },
|
||||
{ metadata: { filename: 'Chapter 02.mp3' } },
|
||||
{ metadata: { filename: 'Chapter 03.mp3' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Mock findFirst to return matching audiobook with filesHash
|
||||
prismaMock.audiobook.findFirst.mockResolvedValue({
|
||||
id: 'matched-audio-1',
|
||||
audibleAsin: 'MATCHED-ASIN',
|
||||
title: 'Matched Book Title',
|
||||
status: 'completed',
|
||||
} as any);
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
const result = await processScanPlex({ jobId: 'job-hash-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify getABSItem was called
|
||||
expect(absApi.getABSItem).toHaveBeenCalledWith('abs-hash-1');
|
||||
|
||||
// Verify audiobook.findFirst was called with hash matching
|
||||
expect(prismaMock.audiobook.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
filesHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
status: 'completed',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Verify triggerABSItemMatch was called with matched ASIN
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-hash-1', 'MATCHED-ASIN');
|
||||
});
|
||||
|
||||
it('falls back to fuzzy matching when no file hash match found', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://abs',
|
||||
authToken: 'token',
|
||||
backendMode: 'audiobookshelf',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
// Return an item without ASIN
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-fuzzy-1',
|
||||
externalId: 'abs-fuzzy-1',
|
||||
title: 'External Book',
|
||||
author: 'Author',
|
||||
asin: null,
|
||||
addedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.plexLibrary.findFirst.mockResolvedValue(null);
|
||||
prismaMock.plexLibrary.create.mockResolvedValue({});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
prismaMock.audiobook.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
|
||||
// Mock getABSItem to return item with audio files
|
||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||
(absApi.getABSItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'abs-fuzzy-1',
|
||||
media: {
|
||||
audioFiles: [{ metadata: { filename: 'Some File.mp3' } }],
|
||||
},
|
||||
});
|
||||
|
||||
// Mock findFirst to return NO match (external content)
|
||||
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
const result = await processScanPlex({ jobId: 'job-fuzzy-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify triggerABSItemMatch was called WITHOUT ASIN (fuzzy fallback)
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-fuzzy-1', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('processSearchIndexers', () => {
|
||||
it('marks request awaiting_search when no results found', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]);
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe('processSearchIndexers', () => {
|
||||
it('queues download job when results are ranked', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]);
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
if (key === 'indexer_flag_config') {
|
||||
return JSON.stringify([]);
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('OIDCAuthProvider', () => {
|
||||
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.isAdmin).toBe(true);
|
||||
expect(result.user?.role).toBe('admin');
|
||||
expect(schedulerMock.triggerJobNow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -300,7 +300,7 @@ describe('OIDCAuthProvider', () => {
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.validateAccess({ id: 'user-3', username: 'user', isAdmin: false, authProvider: 'oidc' });
|
||||
const result = await provider.validateAccess({ id: 'user-3', username: 'user', role: 'user', authProvider: 'oidc' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -314,7 +314,7 @@ describe('OIDCAuthProvider', () => {
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.validateAccess({ id: 'user-4', username: 'user', isAdmin: false, authProvider: 'oidc' });
|
||||
const result = await provider.validateAccess({ id: 'user-4', username: 'user', role: 'user', authProvider: 'oidc' });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -324,7 +324,7 @@ describe('OIDCAuthProvider', () => {
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.validateAccess({ id: 'user-5', username: 'user', isAdmin: false, authProvider: 'oidc' });
|
||||
const result = await provider.validateAccess({ id: 'user-5', username: 'user', role: 'user', authProvider: 'oidc' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('PlexAuthProvider', () => {
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', role: 'user', authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
@@ -207,7 +207,7 @@ describe('PlexAuthProvider', () => {
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', role: 'user', authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
@@ -222,7 +222,7 @@ describe('PlexAuthProvider', () => {
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', role: 'user', authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:token');
|
||||
|
||||
@@ -229,7 +229,6 @@ describe('JobQueueService', () => {
|
||||
const service = new JobQueueService();
|
||||
|
||||
await service.addPlexScanJob('lib-1', true, '/path');
|
||||
await service.addPlexMatchJob('req-1', 'ab-1', 'Title', 'Author');
|
||||
await service.addPlexRecentlyAddedJob('sched-1');
|
||||
await service.addMonitorRssFeedsJob('sched-2');
|
||||
await service.addAudibleRefreshJob('sched-3');
|
||||
@@ -241,26 +240,23 @@ describe('JobQueueService', () => {
|
||||
expect(queueMock.add.mock.calls[0][2].priority).toBe(7);
|
||||
expect(queueMock.add.mock.calls[0][1]).toEqual(expect.objectContaining({ libraryId: 'lib-1', partial: true, path: '/path' }));
|
||||
|
||||
expect(queueMock.add.mock.calls[1][0]).toBe('match_plex');
|
||||
expect(queueMock.add.mock.calls[1][2].priority).toBe(6);
|
||||
expect(queueMock.add.mock.calls[1][0]).toBe('plex_recently_added_check');
|
||||
expect(queueMock.add.mock.calls[1][2].priority).toBe(8);
|
||||
|
||||
expect(queueMock.add.mock.calls[2][0]).toBe('plex_recently_added_check');
|
||||
expect(queueMock.add.mock.calls[2][0]).toBe('monitor_rss_feeds');
|
||||
expect(queueMock.add.mock.calls[2][2].priority).toBe(8);
|
||||
|
||||
expect(queueMock.add.mock.calls[3][0]).toBe('monitor_rss_feeds');
|
||||
expect(queueMock.add.mock.calls[3][2].priority).toBe(8);
|
||||
expect(queueMock.add.mock.calls[3][0]).toBe('audible_refresh');
|
||||
expect(queueMock.add.mock.calls[3][2].priority).toBe(9);
|
||||
|
||||
expect(queueMock.add.mock.calls[4][0]).toBe('audible_refresh');
|
||||
expect(queueMock.add.mock.calls[4][2].priority).toBe(9);
|
||||
expect(queueMock.add.mock.calls[4][0]).toBe('retry_missing_torrents');
|
||||
expect(queueMock.add.mock.calls[4][2].priority).toBe(7);
|
||||
|
||||
expect(queueMock.add.mock.calls[5][0]).toBe('retry_missing_torrents');
|
||||
expect(queueMock.add.mock.calls[5][0]).toBe('retry_failed_imports');
|
||||
expect(queueMock.add.mock.calls[5][2].priority).toBe(7);
|
||||
|
||||
expect(queueMock.add.mock.calls[6][0]).toBe('retry_failed_imports');
|
||||
expect(queueMock.add.mock.calls[6][2].priority).toBe(7);
|
||||
|
||||
expect(queueMock.add.mock.calls[7][0]).toBe('cleanup_seeded_torrents');
|
||||
expect(queueMock.add.mock.calls[7][2].priority).toBe(10);
|
||||
expect(queueMock.add.mock.calls[6][0]).toBe('cleanup_seeded_torrents');
|
||||
expect(queueMock.add.mock.calls[6][2].priority).toBe(10);
|
||||
});
|
||||
|
||||
it('returns queue stats with safe defaults', async () => {
|
||||
@@ -543,7 +539,6 @@ describe('JobQueueService', () => {
|
||||
expect(processorsMock.processMonitorDownload).toHaveBeenCalled();
|
||||
expect(processorsMock.processOrganizeFiles).toHaveBeenCalled();
|
||||
expect(processorsMock.processScanPlex).toHaveBeenCalled();
|
||||
expect(processorsMock.processMatchPlex).toHaveBeenCalled();
|
||||
expect(processorsMock.processPlexRecentlyAddedCheck).toHaveBeenCalled();
|
||||
expect(processorsMock.processMonitorRssFeeds).toHaveBeenCalled();
|
||||
expect(processorsMock.processAudibleRefresh).toHaveBeenCalled();
|
||||
|
||||
@@ -61,17 +61,8 @@ describe('audiobook-matcher', () => {
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('uses narrator matching when author match is weak', async () => {
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
plexGuid: 'guid-narrator',
|
||||
plexRatingKey: null,
|
||||
title: 'Great Book',
|
||||
author: 'Jane Narrator',
|
||||
asin: null,
|
||||
isbn: null,
|
||||
},
|
||||
]);
|
||||
it('returns null when no ASIN match exists (fuzzy matching removed)', async () => {
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
|
||||
const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher');
|
||||
const match = await findPlexMatch({
|
||||
@@ -81,10 +72,10 @@ describe('audiobook-matcher', () => {
|
||||
narrator: 'Jane Narrator',
|
||||
});
|
||||
|
||||
expect(match?.plexGuid).toBe('guid-narrator');
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('matches library items by ASIN, ISBN, then fuzzy match', async () => {
|
||||
it('matches library items by ASIN or ISBN only (no fuzzy fallback)', async () => {
|
||||
const items = [
|
||||
{ id: '1', externalId: 'g1', title: 'Alpha', author: 'Author A', asin: 'ASIN1' },
|
||||
{ id: '2', externalId: 'g2', title: 'Beta', author: 'Author B', isbn: '978-1-23456-789-7' },
|
||||
@@ -98,8 +89,8 @@ describe('audiobook-matcher', () => {
|
||||
const isbnMatch = matchAudiobook({ title: 'x', author: 'y', isbn: '9781234567897' }, items);
|
||||
expect(isbnMatch?.externalId).toBe('g2');
|
||||
|
||||
const fuzzyMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items);
|
||||
expect(fuzzyMatch?.externalId).toBe('g3');
|
||||
const noMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items);
|
||||
expect(noMatch).toBeNull();
|
||||
});
|
||||
|
||||
it('enriches audiobooks with availability and request status', async () => {
|
||||
|
||||
@@ -24,10 +24,6 @@ const axiosMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const jobLoggerMock = vi.hoisted(() => ({
|
||||
createJobLogger: vi.fn(),
|
||||
}));
|
||||
|
||||
const metadataMock = vi.hoisted(() => ({
|
||||
tagMultipleFiles: vi.fn(),
|
||||
checkFfmpegAvailable: vi.fn(),
|
||||
@@ -49,6 +45,12 @@ const loggerMock = vi.hoisted(() => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
forJob: vi.fn(() => ({
|
||||
info: vi.fn().mockResolvedValue(undefined),
|
||||
warn: vi.fn().mockResolvedValue(undefined),
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,7 +81,6 @@ vi.mock('axios', () => ({
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/job-logger', () => jobLoggerMock);
|
||||
vi.mock('@/lib/utils/metadata-tagger', () => metadataMock);
|
||||
vi.mock('@/lib/utils/chapter-merger', () => chapterMock);
|
||||
vi.mock('@/lib/utils/logger', () => loggerMock);
|
||||
@@ -111,13 +112,6 @@ describe('file organizer', () => {
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const logger = {
|
||||
info: vi.fn().mockResolvedValue(undefined),
|
||||
warn: vi.fn().mockResolvedValue(undefined),
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
jobLoggerMock.createJobLogger.mockReturnValue(logger);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
const result = await organizer.organize(
|
||||
'/downloads/book.m4b',
|
||||
@@ -140,7 +134,7 @@ describe('file organizer', () => {
|
||||
expect(result.audioFiles).toEqual([expectedAudio]);
|
||||
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
|
||||
expect(result.filesMovedCount).toBe(1);
|
||||
expect(jobLoggerMock.createJobLogger).toHaveBeenCalledWith('job-1', 'organize');
|
||||
expect(loggerMock.RMABLogger.forJob).toHaveBeenCalledWith('job-1', 'organize');
|
||||
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Tests for file hash generation utility
|
||||
* Documentation: documentation/fixes/file-hash-matching.md
|
||||
*/
|
||||
|
||||
import { generateFilesHash, isValidHash } from '../../src/lib/utils/files-hash';
|
||||
|
||||
describe('generateFilesHash', () => {
|
||||
describe('Basic functionality', () => {
|
||||
it('should generate a 64-character SHA256 hash', () => {
|
||||
const filePaths = ['/path/to/Chapter 01.mp3', '/path/to/Chapter 02.mp3'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
expect(/^[a-f0-9]{64}$/.test(hash)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty string for empty array', () => {
|
||||
const hash = generateFilesHash([]);
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined input', () => {
|
||||
const hash = generateFilesHash(undefined as any);
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for null input', () => {
|
||||
const hash = generateFilesHash(null as any);
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audio file filtering', () => {
|
||||
it('should include all supported audio formats', () => {
|
||||
const filePaths = [
|
||||
'/path/Chapter 01.m4b',
|
||||
'/path/Chapter 02.m4a',
|
||||
'/path/Chapter 03.mp3',
|
||||
'/path/Chapter 04.mp4',
|
||||
'/path/Chapter 05.aa',
|
||||
'/path/Chapter 06.aax',
|
||||
];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should filter out non-audio files', () => {
|
||||
const filePaths = [
|
||||
'/path/Chapter 01.mp3',
|
||||
'/path/Chapter 02.mp3',
|
||||
'/path/cover.jpg',
|
||||
'/path/metadata.nfo',
|
||||
'/path/info.txt',
|
||||
];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
|
||||
// Should only hash the 2 MP3 files
|
||||
const audioOnlyHash = generateFilesHash(['/path/Chapter 01.mp3', '/path/Chapter 02.mp3']);
|
||||
expect(hash).toBe(audioOnlyHash);
|
||||
});
|
||||
|
||||
it('should return empty string when no audio files present', () => {
|
||||
const filePaths = ['/path/cover.jpg', '/path/metadata.nfo', '/path/info.txt'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
|
||||
it('should handle mixed case audio extensions', () => {
|
||||
const filePaths = ['/path/Chapter.MP3', '/path/Chapter.M4B', '/path/Chapter.m4a'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deterministic behavior', () => {
|
||||
it('should generate the same hash for the same files', () => {
|
||||
const filePaths = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3'];
|
||||
const hash1 = generateFilesHash(filePaths);
|
||||
const hash2 = generateFilesHash(filePaths);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('should generate the same hash regardless of input order', () => {
|
||||
const files1 = ['/path/Chapter 03.mp3', '/path/Chapter 01.mp3', '/path/Chapter 02.mp3'];
|
||||
const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('should be case-insensitive for filenames', () => {
|
||||
const files1 = ['/path/CHAPTER 01.mp3', '/path/CHAPTER 02.mp3'];
|
||||
const files2 = ['/path/chapter 01.mp3', '/path/chapter 02.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('should be path-agnostic (only basename matters)', () => {
|
||||
const files1 = ['/path/to/audiobooks/Chapter 01.mp3', '/path/to/audiobooks/Chapter 02.mp3'];
|
||||
const files2 = ['/different/path/Chapter 01.mp3', '/different/path/Chapter 02.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Differentiating behavior', () => {
|
||||
it('should generate different hashes for different files', () => {
|
||||
const files1 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3'];
|
||||
const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 03.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('should generate different hashes for different file counts', () => {
|
||||
const files1 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3'];
|
||||
const files2 = ['/path/Chapter 01.mp3', '/path/Chapter 02.mp3', '/path/Chapter 03.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('should generate different hashes for different extensions', () => {
|
||||
const files1 = ['/path/Chapter 01.mp3'];
|
||||
const files2 = ['/path/Chapter 01.m4b'];
|
||||
|
||||
const hash1 = generateFilesHash(files1);
|
||||
const hash2 = generateFilesHash(files2);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle single file', () => {
|
||||
const hash = generateFilesHash(['/path/audiobook.m4b']);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle files with special characters', () => {
|
||||
const filePaths = [
|
||||
"/path/Chapter 01 - The Hero's Journey.mp3",
|
||||
'/path/Chapter 02 (Part A).mp3',
|
||||
'/path/Chapter 03 [Bonus].mp3',
|
||||
];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle files with Unicode characters', () => {
|
||||
const filePaths = ['/path/Chapitre 01 - Café.mp3', '/path/Kapitel 02 - Müller.mp3'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle duplicate filenames (same file listed twice)', () => {
|
||||
// This shouldn't happen in practice, but we should handle it gracefully
|
||||
const filePaths = ['/path/Chapter 01.mp3', '/path/Chapter 01.mp3'];
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle very long file paths', () => {
|
||||
const longPath = '/very/long/path/'.repeat(20) + 'Chapter 01.mp3';
|
||||
const hash = generateFilesHash([longPath]);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle large number of files', () => {
|
||||
const filePaths = Array.from({ length: 100 }, (_, i) => `/path/Chapter ${String(i + 1).padStart(3, '0')}.mp3`);
|
||||
const hash = generateFilesHash(filePaths);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash.length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
it('should match chapter-merged audiobook', () => {
|
||||
// Before merging: 20 MP3 files
|
||||
const beforeMerge = Array.from({ length: 20 }, (_, i) => `/path/Chapter ${String(i + 1).padStart(2, '0')}.mp3`);
|
||||
|
||||
// After merging: Single M4B file
|
||||
const afterMerge = ['/path/Audiobook.m4b'];
|
||||
|
||||
const hash1 = generateFilesHash(beforeMerge);
|
||||
const hash2 = generateFilesHash(afterMerge);
|
||||
|
||||
// These SHOULD be different (different files)
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('should match Windows and Unix path separators', () => {
|
||||
const windowsPath = ['C:\\Users\\Books\\Chapter 01.mp3', 'C:\\Users\\Books\\Chapter 02.mp3'];
|
||||
const unixPath = ['/home/books/Chapter 01.mp3', '/home/books/Chapter 02.mp3'];
|
||||
|
||||
const hash1 = generateFilesHash(windowsPath);
|
||||
const hash2 = generateFilesHash(unixPath);
|
||||
|
||||
// Should be the same (basename is identical)
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidHash', () => {
|
||||
it('should validate correct SHA256 hashes', () => {
|
||||
const validHash = 'a'.repeat(64);
|
||||
expect(isValidHash(validHash)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate lowercase hex hashes', () => {
|
||||
const validHash = 'abcdef0123456789'.repeat(4);
|
||||
expect(isValidHash(validHash)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate uppercase hex hashes', () => {
|
||||
const validHash = 'ABCDEF0123456789'.repeat(4);
|
||||
expect(isValidHash(validHash)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject hashes with wrong length', () => {
|
||||
expect(isValidHash('abc123')).toBe(false);
|
||||
expect(isValidHash('a'.repeat(63))).toBe(false);
|
||||
expect(isValidHash('a'.repeat(65))).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject hashes with invalid characters', () => {
|
||||
const invalidHash = 'g'.repeat(64);
|
||||
expect(isValidHash(invalidHash)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(isValidHash('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string input', () => {
|
||||
expect(isValidHash(null as any)).toBe(false);
|
||||
expect(isValidHash(undefined as any)).toBe(false);
|
||||
expect(isValidHash(123 as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Component: Job Logger Utility Tests
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const infoMock = vi.fn();
|
||||
const warnMock = vi.fn();
|
||||
const errorMock = vi.fn();
|
||||
const forJobMock = vi.fn(() => ({
|
||||
info: infoMock,
|
||||
warn: warnMock,
|
||||
error: errorMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
forJob: forJobMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('JobLogger', () => {
|
||||
it('logs info, warn, and error messages via RMABLogger', async () => {
|
||||
const { JobLogger } = await import('@/lib/utils/job-logger');
|
||||
const logger = new JobLogger('job-1', 'Context');
|
||||
|
||||
await logger.info('info message', { foo: 'bar' });
|
||||
await logger.warn('warn message');
|
||||
await logger.error('error message', { error: 'boom' });
|
||||
|
||||
expect(forJobMock).toHaveBeenCalledWith('job-1', 'Context');
|
||||
expect(infoMock).toHaveBeenCalledWith('info message', { foo: 'bar' });
|
||||
expect(warnMock).toHaveBeenCalledWith('warn message', undefined);
|
||||
expect(errorMock).toHaveBeenCalledWith('error message', { error: 'boom' });
|
||||
});
|
||||
|
||||
it('creates a job logger via helper', async () => {
|
||||
const { createJobLogger } = await import('@/lib/utils/job-logger');
|
||||
const logger = createJobLogger('job-2', 'Context2');
|
||||
|
||||
await logger.info('message');
|
||||
|
||||
expect(forJobMock).toHaveBeenCalledWith('job-2', 'Context2');
|
||||
expect(infoMock).toHaveBeenCalledWith('message', undefined);
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,939 @@ describe('ranking-algorithm', () => {
|
||||
);
|
||||
expect(lowQuality.some((note: string) => note.includes('Low quality'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('Parenthetical/Bracketed Content Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches "We Are Legion (We Are Bob)" when torrent omits subtitle', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Dennis E. Taylor - Bobiverse - 01 - We Are Legion',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'We Are Legion (We Are Bob)',
|
||||
author: 'Dennis E. Taylor',
|
||||
});
|
||||
|
||||
// Should pass word coverage (required: "we", "are", "legion" all present)
|
||||
// Should get full title match (45 pts) + author match (15 pts)
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches "We Are Legion (We Are Bob)" when torrent includes full title', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Dennis E. Taylor - We Are Legion (We Are Bob)',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'We Are Legion (We Are Bob)',
|
||||
author: 'Dennis E. Taylor',
|
||||
});
|
||||
|
||||
// Should match full title with parentheses
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches "Title [Series Name]" when torrent omits series in brackets', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author Name - Title - Book One',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Title [Series Name]',
|
||||
author: 'Author Name',
|
||||
});
|
||||
|
||||
// Required word is just "title", should match
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches titles with curly braces as optional content', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author - Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title {Extra Info}',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Structured Metadata Prefix Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches "Author - Series - 01 - Title" format correctly', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Brandon Sanderson - Mistborn - 01 - The Final Empire',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Final Empire',
|
||||
author: 'Brandon Sanderson',
|
||||
});
|
||||
|
||||
// Should recognize structured prefix (preceded by " - ")
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('rejects "This Inevitable Ruin Dungeon Crawler Carl" matching "Dungeon Crawler Carl"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'This Inevitable Ruin Dungeon Crawler Carl',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Dungeon Crawler Carl',
|
||||
author: 'Matt Dinniman',
|
||||
});
|
||||
|
||||
// Should NOT get full title match (45 pts) because of unstructured prefix
|
||||
// Should fall back to fuzzy matching (lower score)
|
||||
expect(breakdown.matchScore).toBeLessThan(45);
|
||||
});
|
||||
|
||||
it('matches when author name is in prefix', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Brandon Sanderson The Way of Kings',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Way of Kings',
|
||||
author: 'Brandon Sanderson',
|
||||
});
|
||||
|
||||
// Should recognize author in prefix as acceptable
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches when title is preceded by colon separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Series Name: Book Title - Author',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('matches when title is preceded by em-dash separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author Name — Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author Name',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suffix Validation', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('rejects "The Housemaid\'s Secret" matching "The Housemaid"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Housemaid\'s Secret - Freida McFadden',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Housemaid',
|
||||
author: 'Freida McFadden',
|
||||
});
|
||||
|
||||
// Should NOT get full match because suffix continues with "'s Secret"
|
||||
// Should use fuzzy similarity instead
|
||||
expect(breakdown.matchScore).toBeLessThan(45);
|
||||
});
|
||||
|
||||
it('matches when title is followed by " by Author"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Great Book by Author Name',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Great Book',
|
||||
author: 'Author Name',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches when title is followed by bracketed metadata', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author - Book Title [Unabridged] (2024)',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('matches when title is followed by author name with space', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title John Smith 2024',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'John Smith',
|
||||
});
|
||||
|
||||
// Should recognize author name in suffix
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches when title is at end of string', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Author - Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-Author Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('splits authors on comma separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe, John Smith',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, John Smith',
|
||||
});
|
||||
|
||||
// Should match both authors
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('splits authors on ampersand separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe & John Smith',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe & John Smith',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('splits authors on "and" separator', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe and John Smith',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe and John Smith',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('filters out "translator" role', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, translator',
|
||||
});
|
||||
|
||||
// Should filter out "translator" and only match "Jane Doe"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('filters out "narrator" role', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, narrator',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('gives proportional credit for partial author matches', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title - Jane Doe',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, John Smith, Alice Johnson',
|
||||
});
|
||||
|
||||
// Should get 1/3 author credit (5 pts) + full title (45 pts) = 50 pts
|
||||
expect(breakdown.matchScore).toBeGreaterThanOrEqual(45);
|
||||
expect(breakdown.matchScore).toBeLessThan(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bonus Modifiers', () => {
|
||||
it('applies indexer priority bonus correctly', () => {
|
||||
const torrent1 = { ...baseTorrent, guid: 'torrent1', indexerId: 1 };
|
||||
const torrent2 = { ...baseTorrent, guid: 'torrent2', indexerId: 2 };
|
||||
|
||||
const priorities = new Map<number, number>([
|
||||
[1, 25], // Max priority (100%)
|
||||
[2, 10], // Default priority (40%)
|
||||
]);
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent1, torrent2],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
priorities
|
||||
);
|
||||
|
||||
// torrent1 should rank higher due to priority bonus
|
||||
expect(ranked[0].guid).toBe('torrent1');
|
||||
expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0);
|
||||
expect(ranked[0].bonusModifiers[0].type).toBe('indexer_priority');
|
||||
expect(ranked[0].finalScore).toBeGreaterThan(ranked[0].score);
|
||||
});
|
||||
|
||||
it('applies positive flag bonus (Freeleech)', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: ['Freeleech'],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: 'Freeleech', modifier: 50 }, // +50% bonus
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag');
|
||||
expect(flagBonus).toBeDefined();
|
||||
expect(flagBonus!.value).toBe(0.5); // 50% = 0.5 multiplier
|
||||
expect(flagBonus!.points).toBeGreaterThan(0);
|
||||
expect(ranked[0].finalScore).toBeGreaterThan(ranked[0].score);
|
||||
});
|
||||
|
||||
it('applies negative flag penalty', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: ['Unwanted'],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: 'Unwanted', modifier: -60 }, // -60% penalty
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagPenalty = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag');
|
||||
expect(flagPenalty).toBeDefined();
|
||||
expect(flagPenalty!.value).toBe(-0.6); // -60% = -0.6 multiplier
|
||||
expect(flagPenalty!.points).toBeLessThan(0);
|
||||
expect(ranked[0].finalScore).toBeLessThan(ranked[0].score);
|
||||
});
|
||||
|
||||
it('stacks multiple flag bonuses additively', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: ['Freeleech', 'Double Upload'],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: 'Freeleech', modifier: 50 },
|
||||
{ name: 'Double Upload', modifier: 25 },
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagBonuses = ranked[0].bonusModifiers.filter(m => m.type === 'indexer_flag');
|
||||
expect(flagBonuses.length).toBe(2);
|
||||
|
||||
// Both bonuses should be positive
|
||||
expect(flagBonuses[0].points).toBeGreaterThan(0);
|
||||
expect(flagBonuses[1].points).toBeGreaterThan(0);
|
||||
|
||||
// Total bonus should be sum of both
|
||||
expect(ranked[0].bonusPoints).toBeCloseTo(
|
||||
flagBonuses[0].points + flagBonuses[1].points + ranked[0].bonusModifiers.find(m => m.type === 'indexer_priority')!.points,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('matches flags case-insensitively', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: ['FREELEECH'],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: 'freeleech', modifier: 50 },
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag');
|
||||
expect(flagBonus).toBeDefined();
|
||||
});
|
||||
|
||||
it('trims whitespace when matching flags', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
flags: [' Freeleech '],
|
||||
indexerId: 1,
|
||||
};
|
||||
|
||||
const flagConfigs = [
|
||||
{ name: ' Freeleech ', modifier: 50 },
|
||||
];
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Great Book', author: 'Author Name' },
|
||||
{ flagConfigs }
|
||||
);
|
||||
|
||||
const flagBonus = ranked[0].bonusModifiers.find(m => m.type === 'indexer_flag');
|
||||
expect(flagBonus).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tiebreaker Sorting', () => {
|
||||
it('prefers newer publish date when scores are equal', () => {
|
||||
const older = {
|
||||
...baseTorrent,
|
||||
guid: 'older',
|
||||
publishDate: new Date('2023-01-01'),
|
||||
};
|
||||
const newer = {
|
||||
...baseTorrent,
|
||||
guid: 'newer',
|
||||
publishDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[older, newer],
|
||||
{ title: 'Great Book', author: 'Author Name' }
|
||||
);
|
||||
|
||||
// Both should have same score, newer should rank #1
|
||||
expect(ranked[0].score).toBe(ranked[1].score);
|
||||
expect(ranked[0].guid).toBe('newer');
|
||||
expect(ranked[1].guid).toBe('older');
|
||||
});
|
||||
|
||||
it('ignores publish date when scores differ', () => {
|
||||
const goodOld = {
|
||||
...baseTorrent,
|
||||
guid: 'good-old',
|
||||
title: 'Great Book by Author Name',
|
||||
publishDate: new Date('2020-01-01'),
|
||||
};
|
||||
const badNew = {
|
||||
...baseTorrent,
|
||||
guid: 'bad-new',
|
||||
title: 'Wrong Title',
|
||||
publishDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[badNew, goodOld],
|
||||
{ title: 'Great Book', author: 'Author Name' }
|
||||
);
|
||||
|
||||
// Better match should rank first despite being older
|
||||
expect(ranked[0].guid).toBe('good-old');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Word Coverage Edge Cases', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('filters stop words correctly', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Wild Robot - Peter Brown',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Wild Robot',
|
||||
author: 'Peter Brown',
|
||||
});
|
||||
|
||||
// "the" is a stop word, so only "wild" and "robot" matter
|
||||
// Should get full title match (45) + author match (15) = 60
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('requires 80% coverage of non-stop words', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Harry Potter',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Harry Potter and the Philosopher Stone',
|
||||
author: 'J.K. Rowling',
|
||||
});
|
||||
|
||||
// Required words: "harry", "potter", "philosopher", "stone" (4 words)
|
||||
// Torrent has: "harry", "potter" (2/4 = 50%)
|
||||
// Should fail 80% threshold
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
});
|
||||
|
||||
it('passes when 80% coverage is met', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'J.K. Rowling - Harry Potter Philosopher Stone',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Harry Potter and the Philosopher Stone',
|
||||
author: 'J.K. Rowling',
|
||||
});
|
||||
|
||||
// Required words: "harry", "potter", "philosopher", "stone" (4 words)
|
||||
// "and" and "the" are stop words
|
||||
// Torrent has: all 4 words (100%)
|
||||
// Should pass
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles titles with only stop words gracefully', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'The Book',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
// Should not crash, should fall through to fuzzy matching
|
||||
expect(breakdown.matchScore).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Format Detection', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('detects M4B format from title', () => {
|
||||
const torrent = { ...baseTorrent, title: 'Book Title [M4B]' };
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(10); // M4B with chapters (default)
|
||||
});
|
||||
|
||||
it('detects M4A format from title', () => {
|
||||
const torrent = { ...baseTorrent, title: 'Book Title [M4A]' };
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(6);
|
||||
});
|
||||
|
||||
it('detects MP3 format from title', () => {
|
||||
const torrent = { ...baseTorrent, title: 'Book Title [MP3]' };
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(4);
|
||||
});
|
||||
|
||||
it('uses explicit format field when provided', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title',
|
||||
format: 'M4B' as const,
|
||||
hasChapters: true,
|
||||
};
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(10);
|
||||
});
|
||||
|
||||
it('reduces M4B score when hasChapters is false', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
format: 'M4B' as const,
|
||||
hasChapters: false,
|
||||
};
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(breakdown.formatScore).toBe(9); // M4B without chapters
|
||||
});
|
||||
});
|
||||
|
||||
describe('Author Presence Check (Automatic Mode)', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('rejects torrents with no author when requireAuthor: true', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}, true); // requireAuthor: true
|
||||
|
||||
// No author → automatic rejection
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
expect(breakdown.totalScore).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('accepts torrents with exact author match', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Andy Weir - Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}, true);
|
||||
|
||||
// Has author → should pass
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts torrents with middle initial variations', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Dennis E. Taylor - We Are Legion',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'We Are Legion',
|
||||
author: 'Dennis Taylor', // No middle initial
|
||||
}, true);
|
||||
|
||||
// Should match despite missing middle initial
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts torrents with name order variations', () => {
|
||||
// Torrent has first-last format
|
||||
const torrent1 = {
|
||||
...baseTorrent,
|
||||
title: 'Andy Weir - Project Hail Mary',
|
||||
};
|
||||
|
||||
const breakdown1 = algorithm.getScoreBreakdown(torrent1, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}, true);
|
||||
|
||||
// Torrent has last,first format - should match via core components (andy + weir)
|
||||
const torrent2 = {
|
||||
...baseTorrent,
|
||||
title: 'Weir, Andy - Project Hail Mary',
|
||||
};
|
||||
const breakdown2 = algorithm.getScoreBreakdown(torrent2, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}, true);
|
||||
|
||||
expect(breakdown1.matchScore).toBeGreaterThan(0);
|
||||
expect(breakdown2.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts torrents with reversed name order', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Sanderson, Brandon - The Way of Kings',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Way of Kings',
|
||||
author: 'Brandon Sanderson', // First Last format
|
||||
}, true);
|
||||
|
||||
// Should match "brandon" and "sanderson" within 30 chars
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects torrents with wrong author', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'John Smith - Harry Potter',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Harry Potter',
|
||||
author: 'J.K. Rowling',
|
||||
}, true);
|
||||
|
||||
// Wrong author → rejection
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts when only one of multiple authors matches', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Jane Doe - Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, John Smith, Alice Johnson', // Multiple authors
|
||||
}, true);
|
||||
|
||||
// At least ONE author matches → should pass
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts full author name when request has additional middle name', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Brandon Sanderson - Mistborn',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Mistborn',
|
||||
author: 'Brandon R. Sanderson', // Middle initial added
|
||||
}, true);
|
||||
|
||||
// Core components (Brandon + Sanderson) present
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('filters author roles before checking', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Jane Doe - Book Title',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'Jane Doe, translator', // Role should be filtered
|
||||
}, true);
|
||||
|
||||
// Should match "Jane Doe" and ignore "translator"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Author Presence Check (Interactive Mode)', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('shows all results when requireAuthor: false', () => {
|
||||
const noAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'no-author',
|
||||
title: 'Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const withAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'with-author',
|
||||
title: 'Andy Weir - Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const wrongAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'wrong-author',
|
||||
title: 'John Smith - Project Hail Mary',
|
||||
};
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[noAuthor, withAuthor, wrongAuthor],
|
||||
{ title: 'Project Hail Mary', author: 'Andy Weir' },
|
||||
{ requireAuthor: false } // Interactive mode
|
||||
);
|
||||
|
||||
// All 3 should be in results
|
||||
expect(ranked).toHaveLength(3);
|
||||
|
||||
// Correct author should rank first
|
||||
expect(ranked[0].guid).toBe('with-author');
|
||||
|
||||
// Others should have lower scores but still visible
|
||||
expect(ranked.find(r => r.guid === 'no-author')).toBeDefined();
|
||||
expect(ranked.find(r => r.guid === 'wrong-author')).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters results when requireAuthor: true (automatic mode)', () => {
|
||||
const noAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'no-author',
|
||||
title: 'Project Hail Mary [M4B]',
|
||||
size: 100 * MB, // Above 20 MB threshold
|
||||
};
|
||||
|
||||
const withAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'with-author',
|
||||
title: 'Andy Weir - Project Hail Mary [M4B]',
|
||||
size: 100 * MB,
|
||||
};
|
||||
|
||||
const wrongAuthor = {
|
||||
...baseTorrent,
|
||||
guid: 'wrong-author',
|
||||
title: 'John Smith - Project Hail Mary',
|
||||
size: 100 * MB,
|
||||
};
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[noAuthor, withAuthor, wrongAuthor],
|
||||
{ title: 'Project Hail Mary', author: 'Andy Weir' },
|
||||
{ requireAuthor: true } // Automatic mode (strict)
|
||||
);
|
||||
|
||||
// Only correct author should have matchScore > 0
|
||||
const withMatch = ranked.filter(r => r.breakdown.matchScore > 0);
|
||||
expect(withMatch).toHaveLength(1);
|
||||
expect(withMatch[0].guid).toBe('with-author');
|
||||
|
||||
// Others should have matchScore = 0 (rejected by author check)
|
||||
const noAuthorResult = ranked.find(r => r.guid === 'no-author');
|
||||
const wrongAuthorResult = ranked.find(r => r.guid === 'wrong-author');
|
||||
expect(noAuthorResult?.breakdown.matchScore).toBe(0);
|
||||
expect(wrongAuthorResult?.breakdown.matchScore).toBe(0);
|
||||
});
|
||||
|
||||
it('defaults to requireAuthor: true when not specified', () => {
|
||||
const noAuthor = {
|
||||
...baseTorrent,
|
||||
title: 'Project Hail Mary [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(noAuthor, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
}); // No requireAuthor parameter → defaults to true
|
||||
|
||||
// Should reject (safe default)
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy API Compatibility', () => {
|
||||
it('supports legacy rankTorrents signature with separate parameters', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
indexerId: 1,
|
||||
title: 'Andy Weir - Project Hail Mary',
|
||||
};
|
||||
|
||||
const priorities = new Map<number, number>([[1, 20]]);
|
||||
const flags = [{ name: 'Freeleech', modifier: 50 }];
|
||||
|
||||
// Legacy call: rankTorrents(torrents, audiobook, priorities, flags)
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Project Hail Mary', author: 'Andy Weir' },
|
||||
priorities,
|
||||
flags
|
||||
);
|
||||
|
||||
expect(ranked).toHaveLength(1);
|
||||
expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('supports new rankTorrents signature with options object', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
indexerId: 1,
|
||||
title: 'Andy Weir - Project Hail Mary',
|
||||
};
|
||||
|
||||
const priorities = new Map<number, number>([[1, 20]]);
|
||||
const flags = [{ name: 'Freeleech', modifier: 50 }];
|
||||
|
||||
// New call: rankTorrents(torrents, audiobook, options)
|
||||
const ranked = rankTorrents(
|
||||
[torrent],
|
||||
{ title: 'Project Hail Mary', author: 'Andy Weir' },
|
||||
{
|
||||
indexerPriorities: priorities,
|
||||
flagConfigs: flags,
|
||||
requireAuthor: false
|
||||
}
|
||||
);
|
||||
|
||||
expect(ranked).toHaveLength(1);
|
||||
expect(ranked[0].bonusModifiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user