Admin requests: paginated API & UI overhaul

Add a paginated Admin Requests API and fully refactor the admin requests UI to support filtering, sorting, pagination, and URL state.

- New API: src/app/api/admin/requests/route.ts implements paginated, searchable, filterable, and sortable request listing with proper relation includes and pagination metadata.
- Frontend: RecentRequestsTable rewritten to fetch via SWR (authenticatedFetcher), read/write URL query params, debounce search, support status/user filters, sortable columns, page size selector, and full pagination UI; added loading/error states and toast feedback for actions.
- Admin page updated to use Suspense and the new RecentRequestsTable (component now fetches its own data).
- Settings: deprecated single download-client PUT route now maps updates into the new multi-client format (download_clients JSON), logs deprecation, and invalidates download client manager; settings GET now reads multi-client config for backward compatibility.
- Processors: monitor-download and retry-failed-imports updated to use the download-client-manager and new PathMappingConfig shape for path mapping logic.
- Minor API/schema updates: request-with-torrent schema extended (indexerId, infoUrl, protocol) and setup complete no longer writes legacy path keys.
- Tests updated to reflect API and processor changes.

This change centralizes request management on the server, modernizes the UI for large datasets, and migrates download client settings toward a multi-client configuration while keeping backward compatibility.
This commit is contained in:
kikootwo
2026-02-02 10:24:09 -05:00
parent 2cda6decbe
commit aefc9ef667
15 changed files with 1258 additions and 361 deletions
@@ -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();
});
});