mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
3a9ae4a439
Implements admin approval workflow for user requests with global and per-user auto-approve controls. Adds new request statuses ('awaiting_approval', 'denied'), related API endpoints, and UI for pending approvals. Introduces configurable audiobook organization path template with validation and preview in settings, updates database schema and migrations for new fields.
246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
/**
|
|
* Component: Request Action 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 prowlarrMock = vi.hoisted(() => ({ search: vi.fn() }));
|
|
const rankTorrentsMock = vi.hoisted(() => vi.fn());
|
|
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
|
|
const configState = vi.hoisted(() => ({
|
|
values: new Map<string, string>(),
|
|
}));
|
|
const jobQueueMock = vi.hoisted(() => ({
|
|
addSearchJob: vi.fn(),
|
|
addDownloadJob: vi.fn(),
|
|
}));
|
|
const downloadEbookMock = vi.hoisted(() => vi.fn());
|
|
const fsMock = vi.hoisted(() => ({
|
|
access: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/middleware/auth', () => ({
|
|
requireAuth: requireAuthMock,
|
|
requireAdmin: vi.fn((req: any, handler: any) => handler()),
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
|
getProwlarrService: async () => prowlarrMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/utils/ranking-algorithm', () => ({
|
|
rankTorrents: rankTorrentsMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: () => configServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/job-queue.service', () => ({
|
|
getJobQueueService: () => jobQueueMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/ebook-scraper', () => ({
|
|
downloadEbook: downloadEbookMock,
|
|
}));
|
|
|
|
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
|
|
|
|
describe('Request action routes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
configState.values.clear();
|
|
authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn() };
|
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
|
prismaMock.configuration.findUnique.mockImplementation(
|
|
async ({ where: { key } }: { where: { key: string } }) => {
|
|
const value = configState.values.get(key);
|
|
return value !== undefined ? { value } : null;
|
|
}
|
|
);
|
|
});
|
|
|
|
it('performs interactive search and ranks results', async () => {
|
|
authRequest.json.mockResolvedValue({});
|
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
|
id: 'req-1',
|
|
userId: 'user-1',
|
|
audiobook: { title: 'Title', author: 'Author' },
|
|
});
|
|
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10 }]));
|
|
configServiceMock.get.mockResolvedValueOnce(null);
|
|
prowlarrMock.search.mockResolvedValueOnce([{ title: 'Result', size: 100 }]);
|
|
rankTorrentsMock.mockReturnValueOnce([
|
|
{ title: 'Result', score: 50, breakdown: { matchScore: 50, formatScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 },
|
|
]);
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(payload.results[0].rank).toBe(1);
|
|
});
|
|
|
|
it('triggers manual search job', async () => {
|
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
|
id: 'req-2',
|
|
userId: 'user-1',
|
|
status: 'failed',
|
|
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
|
});
|
|
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-2', status: 'pending' });
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-2' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
|
});
|
|
|
|
it('selects a torrent and queues download', async () => {
|
|
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
|
id: 'req-3',
|
|
userId: 'user-1',
|
|
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
|
|
});
|
|
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'downloading' });
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-3' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns error when ebook sidecar is disabled', async () => {
|
|
configState.values.set('ebook_sidecar_enabled', 'false');
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-4' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/not enabled/);
|
|
});
|
|
|
|
it('returns 404 when request is not found', async () => {
|
|
configState.values.set('ebook_sidecar_enabled', 'true');
|
|
prismaMock.request.findUnique.mockResolvedValueOnce(null);
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(payload.error).toMatch(/not found/);
|
|
});
|
|
|
|
it('returns 400 when request status is not eligible for ebook fetch', async () => {
|
|
configState.values.set('ebook_sidecar_enabled', 'true');
|
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
|
id: 'req-5',
|
|
status: 'pending',
|
|
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
|
});
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-5' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/Cannot fetch e-book/);
|
|
});
|
|
|
|
it('returns 400 when audiobook directory is missing', async () => {
|
|
configState.values.set('ebook_sidecar_enabled', 'true');
|
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
|
id: 'req-6',
|
|
status: 'downloaded',
|
|
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
|
});
|
|
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toMatch(/directory not found/);
|
|
});
|
|
|
|
it('downloads ebook and returns success', async () => {
|
|
configState.values.set('ebook_sidecar_enabled', 'true');
|
|
configState.values.set('media_dir', '/media/audiobooks');
|
|
configState.values.set('audiobook_path_template', '{author}/{title} {asin}');
|
|
configState.values.set('ebook_sidecar_preferred_format', 'epub');
|
|
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
|
|
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
|
|
|
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
|
id: 'req-7',
|
|
status: 'available',
|
|
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
|
});
|
|
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2022-05-01' });
|
|
fsMock.access.mockResolvedValueOnce(undefined);
|
|
downloadEbookMock.mockResolvedValueOnce({
|
|
success: true,
|
|
format: 'epub',
|
|
filePath: '/media/audiobooks/Author/Title ASIN123/Title.epub',
|
|
});
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-7' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(downloadEbookMock).toHaveBeenCalledWith(
|
|
'ASIN123',
|
|
'Title',
|
|
'Author',
|
|
expect.stringContaining('Title ASIN123'),
|
|
'epub',
|
|
'https://ebooks.example',
|
|
undefined,
|
|
'http://flaresolverr'
|
|
);
|
|
});
|
|
|
|
it('returns failure payload when ebook download fails', async () => {
|
|
configState.values.set('ebook_sidecar_enabled', 'true');
|
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
|
id: 'req-8',
|
|
status: 'downloaded',
|
|
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
|
});
|
|
fsMock.access.mockResolvedValueOnce(undefined);
|
|
downloadEbookMock.mockResolvedValueOnce({
|
|
success: false,
|
|
error: 'Download failed',
|
|
});
|
|
|
|
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
|
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-8' }) });
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(false);
|
|
expect(payload.message).toMatch(/Download failed/);
|
|
});
|
|
});
|
|
|
|
|