Files
ReadMeABook/tests/api/requests-id.route.test.ts
T
kikootwo 94dbaf073b Add backend unit test framework and modularize settings UI
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
2026-01-28 11:41:59 -05:00

323 lines
10 KiB
TypeScript

/**
* Component: Request By ID 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 jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn() }));
const requireAuthMock = vi.hoisted(() => vi.fn());
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
getQBittorrentService: async () => qbtMock,
}));
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: async () => sabnzbdMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
describe('Request by ID API routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
nextUrl: new URL('http://localhost/api/requests/req-1'),
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 403 when user is not authorized to view the request', async () => {
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-1',
userId: 'user-2',
});
const { GET } = await import('@/app/api/requests/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('returns request details for the owner', async () => {
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-1',
userId: 'user-1',
audiobook: { id: 'ab-1' },
});
const { GET } = await import('@/app/api/requests/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(payload.request.id).toBe('req-1');
});
it('returns 404 when request does not exist', async () => {
prismaMock.request.findFirst.mockResolvedValueOnce(null);
const { GET } = await import('@/app/api/requests/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'missing' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('NotFound');
});
it('returns 401 when user is missing', async () => {
authRequest.user = null;
const { GET } = await import('@/app/api/requests/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(401);
expect(payload.error).toBe('Unauthorized');
});
it('cancels a request', async () => {
authRequest.json.mockResolvedValue({ action: 'cancel' });
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-2',
userId: 'user-1',
status: 'pending',
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-2',
status: 'cancelled',
audiobook: { id: 'ab-1' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.request.status).toBe('cancelled');
});
it('returns 400 for invalid actions', async () => {
authRequest.json.mockResolvedValue({ action: 'unknown' });
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-2',
userId: 'user-1',
status: 'pending',
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('rejects retry when status is not retryable', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-4',
userId: 'user-1',
status: 'available',
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-4' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('retries a failed request by enqueuing a search job', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-3',
userId: 'user-1',
status: 'failed',
})
.mockResolvedValueOnce({
id: 'req-3',
userId: 'user-1',
audiobook: {
id: 'ab-2',
title: 'Title',
author: 'Author',
audibleAsin: 'ASIN-2',
},
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-3',
status: 'pending',
audiobook: { id: 'ab-2' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-3' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-3', {
id: 'ab-2',
title: 'Title',
author: 'Author',
asin: 'ASIN-2',
});
});
it('retries an import via qBittorrent download history', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-5',
userId: 'user-1',
status: 'warn',
})
.mockResolvedValueOnce({
id: 'req-5',
userId: 'user-1',
audiobook: { id: 'ab-5' },
downloadHistory: [{ torrentHash: 'hash-1', selected: true }],
});
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-5',
status: 'processing',
audiobook: { id: 'ab-5' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-5' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-5', 'ab-5', '/downloads/Book');
});
it('retries an import via SABnzbd download history', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-6',
userId: 'user-1',
status: 'awaiting_import',
})
.mockResolvedValueOnce({
id: 'req-6',
userId: 'user-1',
audiobook: { id: 'ab-6' },
downloadHistory: [{ nzbId: 'nzb-1', selected: true }],
});
sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/usenet/book' });
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-6',
status: 'processing',
audiobook: { id: 'ab-6' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-6' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-6', 'ab-6', '/usenet/book');
});
it('returns 400 when download history is missing for import retry', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-7',
userId: 'user-1',
status: 'warn',
})
.mockResolvedValueOnce({
id: 'req-7',
userId: 'user-1',
audiobook: { id: 'ab-7' },
downloadHistory: [],
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-7' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('returns 400 when download client info is missing for import retry', async () => {
authRequest.json.mockResolvedValue({ action: 'retry' });
prismaMock.request.findFirst
.mockResolvedValueOnce({
id: 'req-8',
userId: 'user-1',
status: 'warn',
})
.mockResolvedValueOnce({
id: 'req-8',
userId: 'user-1',
audiobook: { id: 'ab-8' },
downloadHistory: [{}],
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-8' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
});
it('allows admins to delete requests', async () => {
authRequest.user = { id: 'admin-1', role: 'admin' };
prismaMock.request.delete.mockResolvedValueOnce({});
const { DELETE } = await import('@/app/api/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-4' } });
});
it('blocks delete for non-admin users', async () => {
authRequest.user = { id: 'user-2', role: 'user' };
const { DELETE } = await import('@/app/api/requests/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-9' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
});