Files
ReadMeABook/tests/api/requests-id.route.test.ts
kikootwo b775ccf473 Add cancel confirmation and cancellable statuses
Introduce a unified CANCELLABLE_STATUSES constant and add confirmation UI for cancelling requests. RequestActionsDropdown and RequestCard now show a ConfirmModal before cancelling and use the shared CANCELLABLE_STATUSES to gate cancel actions. The API route imports the constant to enforce server-side validation and uses Prisma.DbNull for selectedTorrent when withdrawing an awaiting-approval request. Tests updated to expect Prisma.DbNull. Improves UX and centralizes cancel logic to avoid duplicated status lists.
2026-05-15 09:49:42 -04:00

408 lines
14 KiB
TypeScript

/**
* Component: Request By ID API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Prisma } from '@/generated/prisma/client';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn(), addNotificationJob: vi.fn().mockResolvedValue(undefined) }));
const requireAuthMock = vi.hoisted(() => vi.fn());
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientServiceForProtocol: 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/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => ({}),
}));
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));
downloadClientManagerMock.getClientServiceForProtocol.mockReset();
});
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',
user: { plexUsername: 'testuser' },
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-2',
status: 'cancelled',
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
});
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');
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_cancelled',
'req-2',
'Test Book',
'Test Author',
'testuser'
);
});
it('cancels an awaiting_approval request and clears selectedTorrent', async () => {
authRequest.json.mockResolvedValue({ action: 'cancel' });
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-ap',
userId: 'user-1',
status: 'awaiting_approval',
user: { plexUsername: 'testuser' },
audiobook: { id: 'ab-ap', title: 'Approval Book', author: 'Some Author' },
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-ap',
status: 'cancelled',
audiobook: { id: 'ab-ap', title: 'Approval Book', author: 'Some Author' },
});
const { PATCH } = await import('@/app/api/requests/[id]/route');
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-ap' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.request.status).toBe('cancelled');
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ selectedTorrent: Prisma.DbNull }),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_cancelled',
'req-ap',
'Approval Book',
'Some Author',
'testuser'
);
});
it('returns 400 when cancelling a request in a non-cancellable status', async () => {
authRequest.json.mockResolvedValue({ action: 'cancel' });
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-2',
userId: 'user-1',
status: 'available',
user: { plexUsername: 'testuser' },
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
});
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('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, downloadClient: 'qbittorrent' }],
});
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue({
clientType: 'qbittorrent',
getDownload: vi.fn().mockResolvedValue({
downloadPath: '/downloads/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, downloadClient: 'sabnzbd' }],
});
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue({
clientType: 'sabnzbd',
getDownload: vi.fn().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');
});
});