mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge branch 'main' into ebook-piecewise
This commit is contained in:
@@ -44,7 +44,7 @@ describe('Admin requests routes', () => {
|
||||
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('returns recent requests', async () => {
|
||||
it('returns recent requests (legacy endpoint)', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-1',
|
||||
@@ -66,6 +66,172 @@ describe('Admin requests routes', () => {
|
||||
expect(payload.requests[0].torrentUrl).toBe('http://torrent');
|
||||
});
|
||||
|
||||
it('returns paginated requests with default params', async () => {
|
||||
prismaMock.request.count.mockResolvedValueOnce(1);
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u-1', plexUsername: 'user' },
|
||||
downloadHistory: [{ torrentUrl: 'http://torrent' }],
|
||||
},
|
||||
]);
|
||||
|
||||
const mockRequest = {
|
||||
url: 'http://localhost/api/admin/requests',
|
||||
};
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/route');
|
||||
const response = await GET(mockRequest as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.requests).toHaveLength(1);
|
||||
expect(payload.total).toBe(1);
|
||||
expect(payload.page).toBe(1);
|
||||
expect(payload.pageSize).toBe(25);
|
||||
expect(payload.totalPages).toBe(1);
|
||||
expect(payload.requests[0].userId).toBe('u-1');
|
||||
});
|
||||
|
||||
it('filters requests by status', async () => {
|
||||
prismaMock.request.count.mockResolvedValueOnce(1);
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'failed',
|
||||
createdAt: new Date(),
|
||||
completedAt: null,
|
||||
errorMessage: 'Search failed',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u-1', plexUsername: 'user' },
|
||||
downloadHistory: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const mockRequest = {
|
||||
url: 'http://localhost/api/admin/requests?status=failed',
|
||||
};
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/route');
|
||||
const response = await GET(mockRequest as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.requests).toHaveLength(1);
|
||||
expect(payload.requests[0].status).toBe('failed');
|
||||
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: 'failed',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('filters requests by userId', async () => {
|
||||
prismaMock.request.count.mockResolvedValueOnce(1);
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
|
||||
user: { id: 'user-123', plexUsername: 'specificuser' },
|
||||
downloadHistory: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const mockRequest = {
|
||||
url: 'http://localhost/api/admin/requests?userId=user-123',
|
||||
};
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/route');
|
||||
const response = await GET(mockRequest as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.requests).toHaveLength(1);
|
||||
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: 'user-123',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('searches requests by title/author', async () => {
|
||||
prismaMock.request.count.mockResolvedValueOnce(1);
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
audiobook: { id: 'ab-1', title: 'Harry Potter', author: 'J.K. Rowling' },
|
||||
user: { id: 'u-1', plexUsername: 'user' },
|
||||
downloadHistory: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const mockRequest = {
|
||||
url: 'http://localhost/api/admin/requests?search=Harry',
|
||||
};
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/route');
|
||||
const response = await GET(mockRequest as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.requests).toHaveLength(1);
|
||||
expect(payload.requests[0].title).toBe('Harry Potter');
|
||||
});
|
||||
|
||||
it('paginates requests correctly', async () => {
|
||||
prismaMock.request.count.mockResolvedValueOnce(100);
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const mockRequest = {
|
||||
url: 'http://localhost/api/admin/requests?page=3&pageSize=10',
|
||||
};
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/route');
|
||||
const response = await GET(mockRequest as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.page).toBe(3);
|
||||
expect(payload.pageSize).toBe(10);
|
||||
expect(payload.totalPages).toBe(10);
|
||||
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 20, // (page - 1) * pageSize = 2 * 10
|
||||
take: 10,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sorts requests by different fields', async () => {
|
||||
prismaMock.request.count.mockResolvedValueOnce(1);
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const mockRequest = {
|
||||
url: 'http://localhost/api/admin/requests?sortBy=title&sortOrder=asc',
|
||||
};
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/route');
|
||||
await GET(mockRequest as any);
|
||||
|
||||
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orderBy: { audiobook: { title: 'asc' } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('soft deletes a request via delete service', async () => {
|
||||
deleteRequestMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
|
||||
@@ -29,6 +29,11 @@ const pathMapperMock = vi.hoisted(() => ({
|
||||
}));
|
||||
const invalidateQbMock = vi.hoisted(() => vi.fn());
|
||||
const invalidateSabMock = vi.hoisted(() => vi.fn());
|
||||
const invalidateDownloadClientManagerMock = vi.hoisted(() => vi.fn());
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getAllClients: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -67,12 +72,20 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
invalidateSABnzbdService: invalidateSabMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
invalidateDownloadClientManager: invalidateDownloadClientManagerMock,
|
||||
}));
|
||||
|
||||
describe('Admin settings core routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
// Reset download client manager mocks with default values
|
||||
downloadClientManagerMock.getAllClients.mockResolvedValue([]);
|
||||
downloadClientManagerMock.testConnection.mockResolvedValue({ success: true, message: 'Connected' });
|
||||
});
|
||||
|
||||
it('returns settings payload', async () => {
|
||||
|
||||
@@ -11,7 +11,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
|
||||
const mutateMock = vi.hoisted(() => vi.fn());
|
||||
const useSWRMock = vi.hoisted(() => vi.fn());
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -19,18 +21,61 @@ const toastMock = vi.hoisted(() => ({
|
||||
warning: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock next/navigation
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
};
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
usePathname: () => '/admin',
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
default: useSWRMock,
|
||||
mutate: mutateMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
authenticatedFetcher: authenticatedFetcherMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
const mockRequestsData = {
|
||||
requests: [
|
||||
{
|
||||
requestId: 'req-1',
|
||||
title: 'Test Audiobook',
|
||||
author: 'Test Author',
|
||||
status: 'pending',
|
||||
userId: 'user-1',
|
||||
user: 'TestUser',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const mockUsersData = {
|
||||
users: [
|
||||
{ id: 'user-1', plexUsername: 'TestUser' },
|
||||
{ id: 'user-2', plexUsername: 'OtherUser' },
|
||||
],
|
||||
};
|
||||
|
||||
let RecentRequestsTable: typeof import('@/app/admin/components/RecentRequestsTable').RecentRequestsTable;
|
||||
|
||||
describe('RecentRequestsTable', () => {
|
||||
@@ -38,10 +83,22 @@ describe('RecentRequestsTable', () => {
|
||||
vi.resetModules();
|
||||
fetchWithAuthMock.mockReset();
|
||||
mutateMock.mockReset();
|
||||
mockRouter.push.mockReset();
|
||||
toastMock.success.mockReset();
|
||||
toastMock.error.mockReset();
|
||||
toastMock.warning.mockReset();
|
||||
|
||||
// Default SWR mock - returns requests and users data
|
||||
useSWRMock.mockImplementation((url: string) => {
|
||||
if (url.includes('/api/admin/requests')) {
|
||||
return { data: mockRequestsData, error: null, isLoading: false };
|
||||
}
|
||||
if (url === '/api/admin/users') {
|
||||
return { data: mockUsersData, error: null, isLoading: false };
|
||||
}
|
||||
return { data: null, error: null, isLoading: false };
|
||||
});
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/components/RequestActionsDropdown.tsx'), () => ({
|
||||
RequestActionsDropdown: ({
|
||||
request,
|
||||
@@ -84,9 +141,57 @@ describe('RecentRequestsTable', () => {
|
||||
});
|
||||
|
||||
it('shows empty state when there are no requests', () => {
|
||||
render(<RecentRequestsTable requests={[]} />);
|
||||
useSWRMock.mockImplementation((url: string) => {
|
||||
if (url.includes('/api/admin/requests')) {
|
||||
return {
|
||||
data: { requests: [], total: 0, page: 1, pageSize: 25, totalPages: 0 },
|
||||
error: null,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
if (url === '/api/admin/users') {
|
||||
return { data: mockUsersData, error: null, isLoading: false };
|
||||
}
|
||||
return { data: null, error: null, isLoading: false };
|
||||
});
|
||||
|
||||
expect(screen.getByText('No Recent Requests')).toBeInTheDocument();
|
||||
render(<RecentRequestsTable />);
|
||||
|
||||
expect(screen.getByText('No Requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state while fetching', () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: null,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
const { container } = render(<RecentRequestsTable />);
|
||||
|
||||
// Should show loading spinner (check for animate-spin class)
|
||||
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders requests table with data', () => {
|
||||
const { container } = render(<RecentRequestsTable />);
|
||||
|
||||
expect(screen.getByText('Test Audiobook')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Author')).toBeInTheDocument();
|
||||
// TestUser appears in both dropdown and table, check for table cell content
|
||||
expect(screen.getByRole('cell', { name: 'TestUser' })).toBeInTheDocument();
|
||||
// Pending status badge (span with specific class)
|
||||
const statusBadge = container.querySelector('span.inline-flex');
|
||||
expect(statusBadge).toHaveTextContent('Pending');
|
||||
});
|
||||
|
||||
it('renders filter controls', () => {
|
||||
render(<RecentRequestsTable />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search by title or author...')).toBeInTheDocument();
|
||||
// Check for status and user dropdowns via their options
|
||||
expect(screen.getByRole('option', { name: 'All Statuses' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'All Users' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a request and refreshes caches', async () => {
|
||||
@@ -95,22 +200,7 @@ describe('RecentRequestsTable', () => {
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
render(<RecentRequestsTable />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Trigger' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
@@ -122,16 +212,10 @@ describe('RecentRequestsTable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/recent');
|
||||
// Should mutate the current API URL and metrics
|
||||
expect(mutateMock).toHaveBeenCalledWith(expect.stringContaining('/api/admin/requests'));
|
||||
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);
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Request deleted successfully');
|
||||
});
|
||||
|
||||
it('warns when ebook fetch fails', async () => {
|
||||
@@ -140,34 +224,76 @@ describe('RecentRequestsTable', () => {
|
||||
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
|
||||
/>
|
||||
);
|
||||
render(<RecentRequestsTable ebookSidecarEnabled />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fetch Ebook Trigger' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-2/fetch-ebook', {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-1/fetch-ebook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(toastMock.warning).toHaveBeenCalledWith(
|
||||
'E-book fetch failed: No ebook available'
|
||||
);
|
||||
expect(toastMock.warning).toHaveBeenCalledWith('E-book fetch failed: No ebook available');
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers manual search', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
render(<RecentRequestsTable />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manual Search Trigger' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-1/manual-search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Manual search triggered');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels a request', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
render(<RecentRequestsTable />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel Trigger' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'cancel' }),
|
||||
});
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Request cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows pagination info', () => {
|
||||
const { container } = render(<RecentRequestsTable />);
|
||||
|
||||
// Check pagination text container exists with expected content
|
||||
const paginationText = container.querySelector('.text-gray-700');
|
||||
expect(paginationText).toHaveTextContent('Showing');
|
||||
expect(paginationText).toHaveTextContent('requests');
|
||||
});
|
||||
|
||||
it('shows error state when fetch fails', () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: null,
|
||||
error: new Error('Network error'),
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
render(<RecentRequestsTable />);
|
||||
|
||||
expect(screen.getByText('Failed to load requests. Please try again.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@ const sabMock = vi.hoisted(() => ({
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
}));
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -40,6 +43,10 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
}));
|
||||
|
||||
describe('processMonitorDownload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -57,10 +64,14 @@ describe('processMonitorDownload', () => {
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
});
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'true',
|
||||
download_client_remote_path: '/remote/done',
|
||||
download_client_local_path: '/downloads',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: true,
|
||||
remotePath: '/remote/done',
|
||||
localPath: '/downloads',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
@@ -161,10 +172,12 @@ describe('processMonitorDownload', () => {
|
||||
timeLeft: 0,
|
||||
downloadPath: '/usenet/complete/Book',
|
||||
});
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
@@ -11,9 +11,11 @@ import { createJobQueueMock } from '../helpers/job-queue';
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = createJobQueueMock();
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
}));
|
||||
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
|
||||
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
||||
|
||||
@@ -29,6 +31,10 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: () => qbtMock,
|
||||
}));
|
||||
@@ -43,17 +49,19 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('queues organize jobs using download client paths', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
audiobook: { id: 'a1', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-1', torrentName: 'Book' }],
|
||||
downloadHistory: [{ torrentHash: 'hash-1', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -74,11 +82,6 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('returns early when no requests await import', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
@@ -90,11 +93,6 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips requests missing download history', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-2',
|
||||
@@ -111,10 +109,14 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('falls back to configured download dir when qBittorrent lookup fails', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'true',
|
||||
download_client_remote_path: '/remote',
|
||||
download_client_local_path: '/downloads',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: true,
|
||||
remotePath: '/remote',
|
||||
localPath: '/downloads',
|
||||
});
|
||||
configMock.get.mockResolvedValue('/remote');
|
||||
|
||||
@@ -122,7 +124,7 @@ describe('processRetryFailedImports', () => {
|
||||
{
|
||||
id: 'req-3',
|
||||
audiobook: { id: 'a3', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-3', torrentName: 'Book' }],
|
||||
downloadHistory: [{ torrentHash: 'hash-3', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -140,16 +142,20 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('uses SABnzbd download path when available', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'true',
|
||||
download_client_remote_path: '/remote/nzb',
|
||||
download_client_local_path: '/downloads',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: true,
|
||||
remotePath: '/remote/nzb',
|
||||
localPath: '/downloads',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-4',
|
||||
audiobook: { id: 'a4', title: 'Book' },
|
||||
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'Book' }],
|
||||
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'Book', downloadClient: 'sabnzbd' }],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -167,17 +173,19 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips SABnzbd retries when download dir is missing', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
configMock.get.mockResolvedValue(null);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-5',
|
||||
audiobook: { id: 'a5', title: 'Book' },
|
||||
downloadHistory: [{ nzbId: 'nzb-2', torrentName: 'Book' }],
|
||||
downloadHistory: [{ nzbId: 'nzb-2', torrentName: 'Book', downloadClient: 'sabnzbd' }],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -191,16 +199,18 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips requests with no client identifiers or names', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-6',
|
||||
audiobook: { id: 'a6', title: 'Book' },
|
||||
downloadHistory: [{}],
|
||||
downloadHistory: [{ downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -212,16 +222,18 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('tracks skipped requests when organize job fails', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-7',
|
||||
audiobook: { id: 'a7', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book' }],
|
||||
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
|
||||
@@ -235,16 +247,18 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips qBittorrent fallbacks when torrent name is missing', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-8',
|
||||
audiobook: { id: 'a8', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-8' }],
|
||||
downloadHistory: [{ torrentHash: 'hash-8', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
|
||||
@@ -258,17 +272,19 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips qBittorrent fallbacks when download_dir is not configured', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
configMock.get.mockResolvedValue(null);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-9',
|
||||
audiobook: { id: 'a9', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book' }],
|
||||
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
|
||||
@@ -281,16 +297,18 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips SABnzbd retries when the client throws', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-10',
|
||||
audiobook: { id: 'a10', title: 'Book' },
|
||||
downloadHistory: [{ nzbId: 'nzb-10', torrentName: 'Book' }],
|
||||
downloadHistory: [{ nzbId: 'nzb-10', torrentName: 'Book', downloadClient: 'sabnzbd' }],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -304,17 +322,19 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips requests without download_dir when no client identifiers exist', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
configMock.get.mockResolvedValue(null);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-11',
|
||||
audiobook: { id: 'a11', title: 'Book' },
|
||||
downloadHistory: [{ torrentName: 'Book' }],
|
||||
downloadHistory: [{ torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user