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:
kikootwo
2026-01-28 10:32:14 -05:00
parent 497849f427
commit a97979358f
111 changed files with 6571 additions and 1426 deletions
@@ -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: [],
});
+1 -1
View File
@@ -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] }]);
+42
View File
@@ -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);
});
});
+111
View File
@@ -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();
});
});
+127
View File
@@ -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();
});
});
+165
View File
@@ -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);
});
});
+72
View File
@@ -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');
+122
View File
@@ -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;
});
});
+123 -4
View File
@@ -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('/');
});
});
+447
View File
@@ -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 });
});
+131
View File
@@ -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);
});
});
});
-38
View File
@@ -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);
});
});
+4 -1
View File
@@ -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);
+167 -1
View File
@@ -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();
});
});
+130 -2
View File
@@ -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');
+10 -15
View File
@@ -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();
+6 -15
View File
@@ -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 () => {
+7 -13
View File
@@ -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();
});
+263
View File
@@ -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);
});
});
-47
View File
@@ -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);
});
});
+933
View File
@@ -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);
});
});
});