mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
31bca0052f
Introduces 'series' and 'seriesPart' fields to the Audiobook model and database schema. Updates API routes, file organization, and path template utilities to support series metadata. Enhances chapter merging logic, improves notification backend testing, and expands test coverage for admin and API routes.
285 lines
10 KiB
TypeScript
285 lines
10 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', 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('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();
|
|
});
|
|
});
|
|
|
|
|