Files
ReadMeABook/tests/api/admin-requests.routes.test.ts
T
kikootwo aefc9ef667 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.
2026-02-02 10:24:09 -05:00

451 lines
15 KiB
TypeScript

/**
* Component: Admin Requests API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const deleteRequestMock = vi.hoisted(() => vi.fn());
const jobQueueMock = vi.hoisted(() => ({
addDownloadJob: vi.fn(),
addSearchJob: vi.fn(),
addNotificationJob: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/request-delete.service', () => ({
deleteRequest: deleteRequestMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
describe('Admin requests routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'admin-1', role: 'admin' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
});
it('returns recent requests (legacy endpoint)', async () => {
prismaMock.request.findMany.mockResolvedValueOnce([
{
id: 'req-1',
status: 'pending',
createdAt: new Date(),
completedAt: null,
errorMessage: null,
audiobook: { title: 'Title', author: 'Author' },
user: { plexUsername: 'user' },
downloadHistory: [{ torrentUrl: 'http://torrent' }],
},
]);
const { GET } = await import('@/app/api/admin/requests/recent/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.requests).toHaveLength(1);
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,
message: 'Deleted',
filesDeleted: 1,
torrentsRemoved: 0,
torrentsKeptSeeding: 0,
torrentsKeptUnlimited: 0,
});
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(deleteRequestMock).toHaveBeenCalledWith('req-1', 'admin-1');
});
it('returns 401 when admin user is missing', async () => {
authRequest.user = null;
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-2' }) });
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toBe('Unauthorized');
});
it('returns 404 when delete service reports missing request', async () => {
deleteRequestMock.mockResolvedValueOnce({
success: false,
error: 'NotFound',
message: 'Missing',
});
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-3' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('NotFound');
});
it('returns 500 when delete service fails', async () => {
deleteRequestMock.mockResolvedValueOnce({
success: false,
error: 'DeleteFailed',
message: 'boom',
});
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) });
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toBe('DeleteFailed');
});
it('returns pending approval requests', async () => {
prismaMock.request.findMany.mockResolvedValueOnce([{ id: 'req-10', status: 'awaiting_approval' }]);
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.count).toBe(1);
});
it('returns 500 when pending approval fetch fails', async () => {
prismaMock.request.findMany.mockRejectedValueOnce(new Error('fetch failed'));
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
const response = await GET({} as any);
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toBe('FetchError');
});
it('returns 401 when approving without a user', async () => {
authRequest.user = null;
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toBe('Unauthorized');
});
it('returns validation error for invalid approval action', async () => {
const request = { json: vi.fn().mockResolvedValue({ action: 'maybe' }) };
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('returns 404 when approving a missing request', async () => {
prismaMock.request.findUnique.mockResolvedValueOnce(null);
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const response = await POST(request as any, { params: Promise.resolve({ id: 'missing' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('NotFound');
});
it('returns 400 when request is not awaiting approval', async () => {
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-2',
status: 'pending',
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
user: { id: 'u1', plexUsername: 'user' },
});
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-2' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('InvalidStatus');
});
it('approves request with a selected torrent and triggers download', async () => {
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-3',
status: 'awaiting_approval',
selectedTorrent: { title: 'Torrent' },
audiobook: { id: 'ab-3', title: 'Title', author: 'Author' },
user: { id: 'u3', plexUsername: 'user3' },
userId: 'u3',
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-3',
status: 'downloading',
audiobook: { id: 'ab-3', title: 'Title', author: 'Author' },
user: { id: 'u3', plexUsername: 'user3' },
userId: 'u3',
});
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-3' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
});
it('approves request without a selected torrent and triggers search', async () => {
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-4',
status: 'awaiting_approval',
selectedTorrent: null,
audiobook: { id: 'ab-4', title: 'Title', author: 'Author', audibleAsin: 'ASIN4' },
user: { id: 'u4', plexUsername: 'user4' },
userId: 'u4',
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-4',
status: 'pending',
audiobook: { id: 'ab-4', title: 'Title', author: 'Author', audibleAsin: 'ASIN4' },
user: { id: 'u4', plexUsername: 'user4' },
userId: 'u4',
});
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-4' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
});
it('denies request without triggering jobs', async () => {
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-5',
status: 'awaiting_approval',
selectedTorrent: null,
audiobook: { id: 'ab-5', title: 'Title', author: 'Author' },
user: { id: 'u5', plexUsername: 'user5' },
userId: 'u5',
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-5',
status: 'denied',
audiobook: { id: 'ab-5', title: 'Title', author: 'Author' },
user: { id: 'u5', plexUsername: 'user5' },
userId: 'u5',
});
const request = { json: vi.fn().mockResolvedValue({ action: 'deny' }) };
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-5' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
});
});