diff --git a/documentation/admin-dashboard.md b/documentation/admin-dashboard.md index 034613b..c67326f 100644 --- a/documentation/admin-dashboard.md +++ b/documentation/admin-dashboard.md @@ -9,7 +9,7 @@ Comprehensive overview of system metrics, active requests, download monitoring, - **Metrics:** Total requests, active downloads, completed/failed requests, total users, system health - **Requests Awaiting Approval:** Grid of requests pending admin approval (approve/deny buttons, auto-refresh) - **Active Downloads:** Real-time table with title, progress, speed, ETA -- **Recent Requests:** Last 50 with status and timestamps +- **Request Management:** Full-featured table with filtering, sorting, pagination - **Quick Actions:** Links to settings, users, scheduled jobs, system logs ## Data Sources @@ -25,8 +25,15 @@ Comprehensive overview of system metrics, active requests, download monitoring, **GET /api/admin/downloads/active** - Request ID, title, progress %, speed, ETA, user -**GET /api/admin/requests/recent** +**GET /api/admin/requests** (Paginated) +- Query params: `page`, `pageSize` (10|25|50|100), `search`, `status`, `userId`, `sortBy`, `sortOrder` +- Returns: `requests[]`, `total`, `page`, `pageSize`, `totalPages` +- Sorting: createdAt (default), completedAt, title, user, status +- Filtering: by status, by user, text search (title/author) + +**GET /api/admin/requests/recent** (Legacy) - Request ID, title, user, status, created/completed dates +- Limited to 50 entries, no filtering **GET /api/admin/requests/pending-approval** - Requests with status 'awaiting_approval', includes audiobook + user details @@ -54,6 +61,18 @@ Comprehensive overview of system metrics, active requests, download monitoring, - Returns: Job logs with request/audiobook/user details, pagination info - Filters: status (all/pending/active/completed/failed/delayed/stuck), type (all job types) +## Request Management Features + +- **Filter Bar:** + - Text search (title/author, 300ms debounce) + - Status dropdown (all statuses) + - User dropdown (all users) + - Clear filters button +- **Sortable Columns:** Click headers to sort by title, user, status, requested, completed +- **Pagination:** Page navigation, page size selector (10/25/50/100), results count +- **URL State:** Filters/sort/page stored in URL query params (shareable, bookmarkable) +- **Actions:** Delete, cancel, manual search, fetch ebook (via dropdown) + ## Features - Auto-refresh every 10 seconds (SWR) diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx index f165669..1735eba 100644 --- a/src/app/admin/components/RecentRequestsTable.tsx +++ b/src/app/admin/components/RecentRequestsTable.tsx @@ -1,16 +1,18 @@ /** - * Component: Admin Recent Requests Table + * Component: Admin Requests Management Table * Documentation: documentation/admin-dashboard.md */ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { formatDistanceToNow } from 'date-fns'; +import useSWR from 'swr'; import { ConfirmDialog } from './ConfirmDialog'; import { RequestActionsDropdown } from './RequestActionsDropdown'; import { mutate } from 'swr'; -import { fetchWithAuth } from '@/lib/utils/api'; +import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api'; import { useToast } from '@/components/ui/Toast'; interface RecentRequest { @@ -18,6 +20,7 @@ interface RecentRequest { title: string; author: string; status: string; + userId: string; user: string; createdAt: Date; completedAt: Date | null; @@ -25,14 +28,50 @@ interface RecentRequest { torrentUrl?: string | null; } -interface RecentRequestsTableProps { +interface User { + id: string; + plexUsername: string; +} + +interface RequestsResponse { requests: RecentRequest[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +interface RecentRequestsTableProps { ebookSidecarEnabled?: boolean; } +const STATUS_OPTIONS = [ + { value: 'all', label: 'All Statuses' }, + { value: 'pending', label: 'Pending' }, + { value: 'awaiting_approval', label: 'Awaiting Approval' }, + { value: 'awaiting_search', label: 'Awaiting Search' }, + { value: 'searching', label: 'Searching' }, + { value: 'downloading', label: 'Downloading' }, + { value: 'processing', label: 'Processing' }, + { value: 'downloaded', label: 'Downloaded' }, + { value: 'awaiting_import', label: 'Awaiting Import' }, + { value: 'available', label: 'Available' }, + { value: 'completed', label: 'Completed' }, + { value: 'failed', label: 'Failed' }, + { value: 'warn', label: 'Warning' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'denied', label: 'Denied' }, +]; + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +type SortField = 'createdAt' | 'completedAt' | 'title' | 'user' | 'status'; +type SortOrder = 'asc' | 'desc'; + function getStatusBadge(status: string) { const styles: Record = { pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', @@ -44,6 +83,7 @@ function getStatusBadge(status: string) { failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', warn: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + denied: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', }; const style = styles[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'; @@ -51,6 +91,7 @@ function getStatusBadge(status: string) { const labels: Record = { awaiting_search: 'Awaiting Search', awaiting_import: 'Awaiting Import', + awaiting_approval: 'Awaiting Approval', }; const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1); @@ -64,7 +105,45 @@ function getStatusBadge(status: string) { ); } -export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: RecentRequestsTableProps) { +function SortIcon({ field, currentSort, currentOrder }: { field: SortField; currentSort: SortField; currentOrder: SortOrder }) { + if (field !== currentSort) { + return ( + + + + ); + } + + return currentOrder === 'asc' ? ( + + + + ) : ( + + + + ); +} + +export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const toast = useToast(); + + // Get filter state from URL + const page = parseInt(searchParams.get('page') || '1', 10); + const pageSize = parseInt(searchParams.get('pageSize') || '25', 10); + const search = searchParams.get('search') || ''; + const status = searchParams.get('status') || 'all'; + const userId = searchParams.get('userId') || ''; + const sortBy = (searchParams.get('sortBy') || 'createdAt') as SortField; + const sortOrder = (searchParams.get('sortOrder') || 'desc') as SortOrder; + + // Local search input state for debouncing + const [searchInput, setSearchInput] = useState(search); + + // Dialog states const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [selectedRequest, setSelectedRequest] = useState<{ id: string; @@ -72,8 +151,74 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R } | null>(null); const [isDeleting, setIsDeleting] = useState(false); const [isFetchingEbook, setIsFetchingEbook] = useState(false); - const toast = useToast(); + // Build API URL with current filters + const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(search)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`; + + // Fetch requests with SWR + const { data, error, isLoading } = useSWR(apiUrl, authenticatedFetcher, { + refreshInterval: 10000, + }); + + // Fetch users for filter dropdown + const { data: usersData } = useSWR<{ users: User[] }>('/api/admin/users', authenticatedFetcher); + + // Update URL with new params + const updateParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + + Object.entries(updates).forEach(([key, value]) => { + if (value === '' || value === 'all' || (key === 'page' && value === 1) || (key === 'pageSize' && value === 25)) { + params.delete(key); + } else { + params.set(key, String(value)); + } + }); + + // Reset to page 1 when filters change (except when changing page itself) + if (!('page' in updates)) { + params.delete('page'); + } + + const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; + router.push(newUrl, { scroll: false }); + }, + [pathname, router, searchParams] + ); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + if (searchInput !== search) { + updateParams({ search: searchInput }); + } + }, 300); + + return () => clearTimeout(timer); + }, [searchInput, search, updateParams]); + + // Sync search input with URL param on mount + useEffect(() => { + setSearchInput(search); + }, [search]); + + const handleSort = (field: SortField) => { + if (field === sortBy) { + updateParams({ sortOrder: sortOrder === 'asc' ? 'desc' : 'asc' }); + } else { + updateParams({ sortBy: field, sortOrder: 'desc' }); + } + }; + + const clearFilters = () => { + setSearchInput(''); + router.push(pathname, { scroll: false }); + }; + + const hasActiveFilters = search || status !== 'all' || userId; + + // Action handlers const handleDeleteClick = (requestId: string, title: string) => { setSelectedRequest({ id: requestId, title }); setShowDeleteConfirm(true); @@ -97,21 +242,14 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R throw new Error(errorData.message || 'Failed to delete request'); } - const result = await response.json(); - - // Show success message - console.log('[Admin] Request deleted:', result); - // Refresh the requests list - await mutate('/api/admin/requests/recent'); + await mutate(apiUrl); await mutate('/api/admin/metrics'); - - // Invalidate audiobook caches to update request status on home/search pages await mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks')); - // Close dialog setShowDeleteConfirm(false); setSelectedRequest(null); + toast.success('Request deleted successfully'); } catch (error) { console.error('[Admin] Failed to delete request:', error); toast.error(`Failed to delete request: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -139,9 +277,8 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R throw new Error(errorData.message || 'Failed to trigger manual search'); } - console.log('[Admin] Manual search triggered for request:', requestId); - // Refresh the requests list - await mutate('/api/admin/requests/recent'); + toast.success('Manual search triggered'); + await mutate(apiUrl); } catch (error) { console.error('[Admin] Failed to trigger manual search:', error); toast.error(`Failed to trigger manual search: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -163,9 +300,8 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R throw new Error(errorData.message || 'Failed to cancel request'); } - console.log('[Admin] Request cancelled:', requestId); - // Refresh the requests list - await mutate('/api/admin/requests/recent'); + toast.success('Request cancelled'); + await mutate(apiUrl); } catch (error) { console.error('[Admin] Failed to cancel request:', error); toast.error(`Failed to cancel request: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -182,16 +318,16 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R }, }); - const data = await response.json(); + const responseData = await response.json(); if (!response.ok) { - throw new Error(data.error || data.message || 'Failed to fetch e-book'); + throw new Error(responseData.error || responseData.message || 'Failed to fetch e-book'); } - if (data.success) { - toast.success(data.message || 'E-book fetched successfully'); + if (responseData.success) { + toast.success(responseData.message || 'E-book fetched successfully'); } else { - toast.warning(`E-book fetch failed: ${data.message}`); + toast.warning(`E-book fetch failed: ${responseData.message}`); } } catch (error) { console.error('[Admin] Failed to fetch e-book:', error); @@ -201,16 +337,104 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R } }; - if (requests.length === 0) { + // Render loading state + if (isLoading && !data) { return (
-
-
- +
+
+
+ ); + } + + // Render error state + if (error) { + return ( +
+
+ Failed to load requests. Please try again. +
+
+ ); + } + + const requests = data?.requests || []; + const total = data?.total || 0; + const totalPages = data?.totalPages || 1; + + // Calculate display range + const startIndex = (page - 1) * pageSize + 1; + const endIndex = Math.min(page * pageSize, total); + + return ( +
+ {/* Filter Bar */} +
+
+ {/* Search Input */} +
+
+ + + +
+ setSearchInput(e.target.value)} + className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + /> +
+ + {/* Status Filter */} + + + {/* User Filter */} + + + {/* Clear Filters Button */} + {hasActiveFilters && ( + + )} +
+
+ + {/* Table */} + {requests.length === 0 ? ( +
+
+

- No Recent Requests + {hasActiveFilters ? 'No Matching Requests' : 'No Requests'}

- No audiobook requests have been made yet. + {hasActiveFilters + ? 'Try adjusting your filters or search terms.' + : 'No audiobook requests have been made yet.'}

-
- ); - } - - return ( -
-
- - - - - - - - - - - - - {requests.map((request) => ( - - - - - - - - - ))} - -
- Audiobook - - User - - Status - - Requested - - Completed - - Actions -
-
-
- {request.title} + ) : ( + <> +
+ + + + + + + + + + + + + {requests.map((request) => ( + + + + + + + + + ))} + +
handleSort('title')} + > +
+ Audiobook +
-
- {request.author} +
handleSort('user')} + > +
+ User +
- {request.errorMessage && (request.status === 'failed' || request.status === 'warn') && ( -
- {request.errorMessage} +
handleSort('status')} + > +
+ Status + +
+
handleSort('createdAt')} + > +
+ Requested + +
+
handleSort('completedAt')} + > +
+ Completed + +
+
+ Actions +
+
+
+ {request.title} +
+
+ {request.author} +
+ {request.errorMessage && (request.status === 'failed' || request.status === 'warn') && ( +
+ {request.errorMessage} +
+ )}
- )} +
+ {request.user} + {getStatusBadge(request.status)} + {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })} + + {request.completedAt + ? formatDistanceToNow(new Date(request.completedAt), { + addSuffix: true, + }) + : '-'} + + +
+
+ + {/* Pagination */} +
+
+ {/* Results info */} +
+ Showing {startIndex} to{' '} + {endIndex} of{' '} + {total} requests +
+ +
+ {/* Page size selector */} +
+ + +
+ + {/* Page navigation */} +
+ + + + {/* Page numbers */} +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum: number; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (page <= 3) { + pageNum = i + 1; + } else if (page >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = page - 2 + i; + } + return ( + + ); + })}
-
- {request.user} - {getStatusBadge(request.status)} - {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })} - - {request.completedAt - ? formatDistanceToNow(new Date(request.completedAt), { - addSuffix: true, - }) - : '-'} - - -
-
+ + + +
+
+ + + + )} {/* Confirm Dialog */} {metricsError?.message || downloadsError?.message || - requestsError?.message || 'Failed to load dashboard data'}

@@ -490,15 +483,24 @@ function AdminDashboardContent() { - {/* Recent Requests */} + {/* Request Management */}

- Recent Requests + Request Management

- + +
+
+
+
+ } + > + + {/* Quick Actions */} diff --git a/src/app/api/admin/requests/route.ts b/src/app/api/admin/requests/route.ts new file mode 100644 index 0000000..8c50d00 --- /dev/null +++ b/src/app/api/admin/requests/route.ts @@ -0,0 +1,156 @@ +/** + * Component: Admin Requests API (Paginated) + * Documentation: documentation/admin-dashboard.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { Prisma } from '@/generated/prisma'; + +const logger = RMABLogger.create('API.Admin.Requests'); + +const VALID_SORT_FIELDS = ['createdAt', 'completedAt', 'title', 'user', 'status'] as const; +const VALID_SORT_ORDERS = ['asc', 'desc'] as const; +const VALID_PAGE_SIZES = [10, 25, 50, 100] as const; + +type SortField = (typeof VALID_SORT_FIELDS)[number]; +type SortOrder = (typeof VALID_SORT_ORDERS)[number]; + +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { searchParams } = new URL(request.url); + + // Parse query parameters + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); + const pageSizeParam = parseInt(searchParams.get('pageSize') || '25', 10); + const pageSize = VALID_PAGE_SIZES.includes(pageSizeParam as (typeof VALID_PAGE_SIZES)[number]) + ? pageSizeParam + : 25; + const search = searchParams.get('search')?.trim() || ''; + const status = searchParams.get('status') || ''; + const userId = searchParams.get('userId') || ''; + const sortByParam = searchParams.get('sortBy') || 'createdAt'; + const sortBy: SortField = VALID_SORT_FIELDS.includes(sortByParam as SortField) + ? (sortByParam as SortField) + : 'createdAt'; + const sortOrderParam = searchParams.get('sortOrder') || 'desc'; + const sortOrder: SortOrder = VALID_SORT_ORDERS.includes(sortOrderParam as SortOrder) + ? (sortOrderParam as SortOrder) + : 'desc'; + + // Build where clause + const where: Prisma.RequestWhereInput = { + deletedAt: null, + }; + + // Filter by status + if (status && status !== 'all') { + where.status = status; + } + + // Filter by user + if (userId) { + where.userId = userId; + } + + // Search by title or author + if (search) { + where.audiobook = { + OR: [ + { title: { contains: search, mode: 'insensitive' } }, + { author: { contains: search, mode: 'insensitive' } }, + ], + }; + } + + // Build orderBy clause + let orderBy: Prisma.RequestOrderByWithRelationInput; + switch (sortBy) { + case 'title': + orderBy = { audiobook: { title: sortOrder } }; + break; + case 'user': + orderBy = { user: { plexUsername: sortOrder } }; + break; + case 'completedAt': + // Sort nulls last for completedAt + orderBy = { completedAt: { sort: sortOrder, nulls: 'last' } }; + break; + case 'status': + orderBy = { status: sortOrder }; + break; + case 'createdAt': + default: + orderBy = { createdAt: sortOrder }; + break; + } + + // Get total count for pagination + const total = await prisma.request.count({ where }); + + // Get paginated requests + const requests = await prisma.request.findMany({ + where, + include: { + audiobook: { + select: { + id: true, + title: true, + author: true, + }, + }, + user: { + select: { + id: true, + plexUsername: true, + }, + }, + downloadHistory: { + where: { + selected: true, + }, + select: { + torrentUrl: true, + }, + take: 1, + }, + }, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + // Format response + const formatted = requests.map((request) => ({ + requestId: request.id, + title: request.audiobook.title, + author: request.audiobook.author, + status: request.status, + userId: request.user.id, + user: request.user.plexUsername, + createdAt: request.createdAt, + completedAt: request.completedAt, + errorMessage: request.errorMessage, + torrentUrl: request.downloadHistory[0]?.torrentUrl || null, + })); + + return NextResponse.json({ + requests: formatted, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }); + } catch (error) { + logger.error('Failed to fetch requests', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to fetch requests' }, { status: 500 }); + } + }); + }); +} diff --git a/src/app/api/admin/settings/download-client/route.ts b/src/app/api/admin/settings/download-client/route.ts index 673b887..65b1289 100644 --- a/src/app/api/admin/settings/download-client/route.ts +++ b/src/app/api/admin/settings/download-client/route.ts @@ -1,13 +1,19 @@ /** - * Component: Admin Download Client Settings API - * Documentation: documentation/settings-pages.md + * Component: Admin Download Client Settings API (DEPRECATED) + * Documentation: documentation/phase3/download-clients.md + * + * DEPRECATED: This route is deprecated in favor of /api/admin/settings/download-clients + * which supports multiple download clients. This route is maintained for backward + * compatibility but updates are written to the new multi-client format. */ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; -import { prisma } from '@/lib/db'; +import { getConfigService } from '@/lib/services/config.service'; +import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service'; import { PathMapper } from '@/lib/utils/path-mapper'; import { RMABLogger } from '@/lib/utils/logger'; +import { randomUUID } from 'crypto'; const logger = RMABLogger.create('API.Admin.Settings.DownloadClient'); @@ -26,6 +32,8 @@ export async function PUT(request: NextRequest) { localPath, } = await request.json(); + logger.warn('DEPRECATED: Using legacy single-client API. Please use /api/admin/settings/download-clients instead.'); + // Validate type if (type !== 'qbittorrent' && type !== 'sabnzbd') { return NextResponse.json( @@ -78,69 +86,51 @@ export async function PUT(request: NextRequest) { } } - // Update configuration - await prisma.configuration.upsert({ - where: { key: 'download_client_type' }, - update: { value: type }, - create: { key: 'download_client_type', value: type }, - }); + // Get existing clients from new format + const config = await getConfigService(); + const manager = getDownloadClientManager(config); + const existingClients = await manager.getAllClients(); - await prisma.configuration.upsert({ - where: { key: 'download_client_url' }, - update: { value: url }, - create: { key: 'download_client_url', value: url }, - }); + // Find existing client of same type to update, or create new + const existingIndex = existingClients.findIndex(c => c.type === type); - await prisma.configuration.upsert({ - where: { key: 'download_client_username' }, - update: { value: username }, - create: { key: 'download_client_username', value: username }, - }); + const updatedClient: DownloadClientConfig = { + id: existingIndex >= 0 ? existingClients[existingIndex].id : randomUUID(), + type, + name: type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd', + enabled: true, + url, + username: username || undefined, + // Only update password if it's not the masked value + password: password.startsWith('••••') && existingIndex >= 0 + ? existingClients[existingIndex].password + : password, + disableSSLVerify: disableSSLVerify || false, + remotePathMappingEnabled: remotePathMappingEnabled || false, + remotePath: remotePath || undefined, + localPath: localPath || undefined, + category: existingIndex >= 0 ? existingClients[existingIndex].category : 'readmeabook', + }; - // Only update password if it's not the masked value - if (!password.startsWith('••••')) { - await prisma.configuration.upsert({ - where: { key: 'download_client_password' }, - update: { value: password }, - create: { key: 'download_client_password', value: password }, - }); + // Update or add client + let updatedClients: DownloadClientConfig[]; + if (existingIndex >= 0) { + updatedClients = [...existingClients]; + updatedClients[existingIndex] = updatedClient; + } else { + updatedClients = [...existingClients, updatedClient]; } - // Save SSL verification setting - await prisma.configuration.upsert({ - where: { key: 'download_client_disable_ssl_verify' }, - update: { value: disableSSLVerify ? 'true' : 'false' }, - create: { - key: 'download_client_disable_ssl_verify', - value: disableSSLVerify ? 'true' : 'false', - }, - }); + // Save to new format + await config.setMany([ + { key: 'download_clients', value: JSON.stringify(updatedClients) }, + ]); - // Save remote path mapping configuration - await prisma.configuration.upsert({ - where: { key: 'download_client_remote_path_mapping_enabled' }, - update: { value: remotePathMappingEnabled ? 'true' : 'false' }, - create: { - key: 'download_client_remote_path_mapping_enabled', - value: remotePathMappingEnabled ? 'true' : 'false', - }, - }); + logger.info('Download client settings updated via legacy API', { type, id: updatedClient.id }); - await prisma.configuration.upsert({ - where: { key: 'download_client_remote_path' }, - update: { value: remotePath || '' }, - create: { key: 'download_client_remote_path', value: remotePath || '' }, - }); + // Invalidate caches + invalidateDownloadClientManager(); - await prisma.configuration.upsert({ - where: { key: 'download_client_local_path' }, - update: { value: localPath || '' }, - create: { key: 'download_client_local_path', value: localPath || '' }, - }); - - logger.info('Download client settings updated'); - - // Invalidate download client service singleton to force reload of credentials and URL if (type === 'qbittorrent') { const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service'); invalidateQBittorrentService(); @@ -152,6 +142,8 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ success: true, message: 'Download client settings updated successfully', + deprecated: true, + warning: 'This API is deprecated. Please use /api/admin/settings/download-clients instead.', }); } catch (error) { logger.error('Failed to update download client settings', { error: error instanceof Error ? error.message : String(error) }); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 059b4c7..4895165 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -81,17 +81,46 @@ export async function GET(request: NextRequest) { url: configMap.get('prowlarr_url') || '', apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')), }, - downloadClient: { - type: configMap.get('download_client_type') || 'qbittorrent', - url: configMap.get('download_client_url') || '', - username: configMap.get('download_client_username') || '', - password: maskValue('password', configMap.get('download_client_password')), - disableSSLVerify: configMap.get('download_client_disable_ssl_verify') === 'true', - seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'), - remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true', - remotePath: configMap.get('download_client_remote_path') || '', - localPath: configMap.get('download_client_local_path') || '', - }, + // downloadClient is populated from multi-client format for backward compatibility + // The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients + downloadClient: (() => { + // Try to read from new multi-client format first + const downloadClientsJson = configMap.get('download_clients'); + if (downloadClientsJson) { + try { + const clients = JSON.parse(downloadClientsJson); + // Return the first enabled client for backward compatibility + const firstClient = clients.find((c: any) => c.enabled) || clients[0]; + if (firstClient) { + return { + type: firstClient.type || 'qbittorrent', + url: firstClient.url || '', + username: firstClient.username || '', + password: maskValue('password', firstClient.password), + disableSSLVerify: firstClient.disableSSLVerify === true, + seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'), + remotePathMappingEnabled: firstClient.remotePathMappingEnabled === true, + remotePath: firstClient.remotePath || '', + localPath: firstClient.localPath || '', + }; + } + } catch { + // Fall through to legacy format + } + } + // Fall back to legacy flat keys + return { + type: configMap.get('download_client_type') || 'qbittorrent', + url: configMap.get('download_client_url') || '', + username: configMap.get('download_client_username') || '', + password: maskValue('password', configMap.get('download_client_password')), + disableSSLVerify: configMap.get('download_client_disable_ssl_verify') === 'true', + seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'), + remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true', + remotePath: configMap.get('download_client_remote_path') || '', + localPath: configMap.get('download_client_local_path') || '', + }; + })(), paths: { downloadDir: configMap.get('download_dir') || '/downloads', mediaDir: configMap.get('media_dir') || '/media/audiobooks', diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts index b2cdab7..8d99adb 100644 --- a/src/app/api/audiobooks/request-with-torrent/route.ts +++ b/src/app/api/audiobooks/request-with-torrent/route.ts @@ -35,12 +35,15 @@ const RequestWithTorrentSchema = z.object({ seeders: z.number().optional(), // Optional for NZB/Usenet results leechers: z.number().optional(), // Optional for NZB/Usenet results indexer: z.string(), + indexerId: z.number().optional(), // Prowlarr indexer ID downloadUrl: z.string(), + infoUrl: z.string().optional(), // Link to indexer's info page publishDate: z.string().transform((str) => new Date(str)), infoHash: z.string().optional(), format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(), bitrate: z.string().optional(), hasChapters: z.boolean().optional(), + protocol: z.enum(['torrent', 'usenet']).optional(), // Protocol from Prowlarr API }), }); diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts index 0e74f57..9ebd744 100644 --- a/src/app/api/setup/complete/route.ts +++ b/src/app/api/setup/complete/route.ts @@ -418,20 +418,6 @@ export async function POST(request: NextRequest) { create: { key: 'download_clients', value: JSON.stringify(downloadClientsArray) }, }); - // Legacy: Keep old keys for backward compatibility with migration - // (Will be cleaned up by migration on first access) - await prisma.configuration.upsert({ - where: { key: 'download_client_remote_path' }, - update: { value: downloadClient.remotePath || '' }, - create: { key: 'download_client_remote_path', value: downloadClient.remotePath || '' }, - }); - - await prisma.configuration.upsert({ - where: { key: 'download_client_local_path' }, - update: { value: downloadClient.localPath || '' }, - create: { key: 'download_client_local_path', value: downloadClient.localPath || '' }, - }); - // Path configuration await prisma.configuration.upsert({ where: { key: 'download_dir' }, diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts index 4cd509e..bc8133a 100644 --- a/src/lib/processors/monitor-download.processor.ts +++ b/src/lib/processors/monitor-download.processor.ts @@ -8,8 +8,9 @@ import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queu import { prisma } from '../db'; import { getQBittorrentService } from '../integrations/qbittorrent.service'; import { RMABLogger } from '../utils/logger'; -import { PathMapper } from '../utils/path-mapper'; +import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { getConfigService } from '../services/config.service'; +import { getDownloadClientManager } from '../services/download-client-manager.service'; /** * Helper function to retry getTorrent with exponential backoff @@ -130,20 +131,23 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P throw new Error('Download path not available from download client'); } - // Load path mapping configuration + // Get path mapping configuration from the specific download client const configService = getConfigService(); - const pathMappingConfig = await configService.getMany([ - 'download_client_remote_path_mapping_enabled', - 'download_client_remote_path', - 'download_client_local_path', - ]); + const manager = getDownloadClientManager(configService); + const protocol = downloadClient === 'sabnzbd' ? 'usenet' : 'torrent'; + const clientConfig = await manager.getClientForProtocol(protocol); + + // Build path mapping config from client settings + const pathMappingConfig: PathMappingConfig = clientConfig && clientConfig.remotePathMappingEnabled + ? { + enabled: true, + remotePath: clientConfig.remotePath || '', + localPath: clientConfig.localPath || '', + } + : { enabled: false, remotePath: '', localPath: '' }; // Apply remote-to-local path transformation if enabled - const organizePath = PathMapper.transform(downloadPath, { - enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true', - remotePath: pathMappingConfig.download_client_remote_path || '', - localPath: pathMappingConfig.download_client_local_path || '', - }); + const organizePath = PathMapper.transform(downloadPath, pathMappingConfig); logger.info(`Download completed`, { downloadClient, diff --git a/src/lib/processors/retry-failed-imports.processor.ts b/src/lib/processors/retry-failed-imports.processor.ts index ac1d41c..17258c2 100644 --- a/src/lib/processors/retry-failed-imports.processor.ts +++ b/src/lib/processors/retry-failed-imports.processor.ts @@ -9,7 +9,8 @@ import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; import { getJobQueueService } from '../services/job-queue.service'; import { getConfigService } from '../services/config.service'; -import { PathMapper } from '../utils/path-mapper'; +import { getDownloadClientManager } from '../services/download-client-manager.service'; +import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; export interface RetryFailedImportsPayload { jobId?: string; @@ -23,18 +24,23 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo logger.info('Starting retry job for requests awaiting import...'); try { - // Load path mapping configuration once + // Initialize config and download client manager const configService = getConfigService(); - const pathMappingConfig = await configService.getMany([ - 'download_client_remote_path_mapping_enabled', - 'download_client_remote_path', - 'download_client_local_path', - ]); + const manager = getDownloadClientManager(configService); - const mappingConfig = { - enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true', - remotePath: pathMappingConfig.download_client_remote_path || '', - localPath: pathMappingConfig.download_client_local_path || '', + // Helper function to get path mapping config for a specific download client type + const getPathMappingForClient = async (clientType: string): Promise => { + const protocol = clientType === 'sabnzbd' ? 'usenet' : 'torrent'; + const clientConfig = await manager.getClientForProtocol(protocol); + + if (clientConfig && clientConfig.remotePathMappingEnabled) { + return { + enabled: true, + remotePath: clientConfig.remotePath || '', + localPath: clientConfig.localPath || '', + }; + } + return { enabled: false, remotePath: '', localPath: '' }; }; // Find all active requests in awaiting_import status @@ -83,6 +89,10 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo let downloadPath: string; // Try to get download path from the appropriate download client + // Get path mapping for this specific download client + const clientType = downloadHistory.downloadClient || 'qbittorrent'; + const mappingConfig = await getPathMappingForClient(clientType); + if (downloadHistory.torrentHash) { // qBittorrent download try { diff --git a/tests/api/admin-requests.routes.test.ts b/tests/api/admin-requests.routes.test.ts index d8c5614..2ec1586 100644 --- a/tests/api/admin-requests.routes.test.ts +++ b/tests/api/admin-requests.routes.test.ts @@ -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, diff --git a/tests/api/admin-settings-core.routes.test.ts b/tests/api/admin-settings-core.routes.test.ts index cfe62eb..df74c54 100644 --- a/tests/api/admin-settings-core.routes.test.ts +++ b/tests/api/admin-settings-core.routes.test.ts @@ -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 () => { diff --git a/tests/app/admin/components/RecentRequestsTable.test.tsx b/tests/app/admin/components/RecentRequestsTable.test.tsx index 967a311..6ffa65f 100644 --- a/tests/app/admin/components/RecentRequestsTable.test.tsx +++ b/tests/app/admin/components/RecentRequestsTable.test.tsx @@ -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(); + 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(); + + expect(screen.getByText('No Requests')).toBeInTheDocument(); + }); + + it('shows loading state while fetching', () => { + useSWRMock.mockImplementation(() => ({ + data: null, + error: null, + isLoading: true, + })); + + const { container } = render(); + + // Should show loading spinner (check for animate-spin class) + expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('renders requests table with data', () => { + const { container } = render(); + + 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(); + + 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( - - ); + render(); 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( - - ); + render(); 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(); + + 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(); + + 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(); + + // 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(); + + expect(screen.getByText('Failed to load requests. Please try again.')).toBeInTheDocument(); + }); }); diff --git a/tests/processors/monitor-download.processor.test.ts b/tests/processors/monitor-download.processor.test.ts index fd4ba60..78087e3 100644 --- a/tests/processors/monitor-download.processor.test.ts +++ b/tests/processors/monitor-download.processor.test.ts @@ -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({}); diff --git a/tests/processors/retry-failed-imports.processor.test.ts b/tests/processors/retry-failed-imports.processor.test.ts index 50bd70d..3fe8d83 100644 --- a/tests/processors/retry-failed-imports.processor.test.ts +++ b/tests/processors/retry-failed-imports.processor.test.ts @@ -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' }], }, ]);