mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add first-class ebook request support and UI
Implements first-class ebook requests with their own type, parent-child relationship to audiobook requests, and separate status flow. Updates database schema and migrations to support 'type' and 'parentRequestId' fields on requests. Adds processors and job types for ebook search and direct HTTP download from Anna's Archive, with FlareSolverr integration for Cloudflare bypass. Enhances admin UI tables and request actions to display and manage ebook requests, including orange badge and source links. Updates documentation to reflect new ebook support, configuration, and behavior.
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Component: Direct Download Processor Tests
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addOrganizeJob: vi.fn(() => Promise.resolve()),
|
||||
addMonitorDirectDownloadJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const ebookScraperMock = vi.hoisted(() => ({
|
||||
extractDownloadUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn(),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const createWriteStreamMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock);
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
createWriteStream: createWriteStreamMock,
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
}));
|
||||
|
||||
describe('processStartDirectDownload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'downloads_dir') return '/downloads';
|
||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('updates request status to downloading', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
|
||||
});
|
||||
|
||||
// Mock successful download
|
||||
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
|
||||
url: 'https://direct.example.com/book.epub',
|
||||
format: 'epub',
|
||||
});
|
||||
|
||||
// Mock axios stream
|
||||
const mockWriteStream = {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === 'finish') setTimeout(cb, 10);
|
||||
return mockWriteStream;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
|
||||
const mockDataStream = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
pipe: vi.fn().mockReturnValue(mockWriteStream),
|
||||
};
|
||||
axiosMock.mockResolvedValue({
|
||||
data: mockDataStream,
|
||||
headers: { 'content-length': '1000000' },
|
||||
});
|
||||
|
||||
fsMock.stat.mockResolvedValue({ size: 1000000 });
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
audiobookId: 'ab-1',
|
||||
audiobook: { id: 'ab-1' },
|
||||
});
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processStartDirectDownload({
|
||||
requestId: 'req-1',
|
||||
downloadHistoryId: 'dh-1',
|
||||
downloadUrl: 'https://slow.example.com/book',
|
||||
targetFilename: 'Test Book.epub',
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
// Check status updates
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dh-1' },
|
||||
data: expect.objectContaining({
|
||||
downloadStatus: 'downloading',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers organize job after successful download', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
|
||||
});
|
||||
|
||||
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
|
||||
url: 'https://direct.example.com/book.epub',
|
||||
format: 'epub',
|
||||
});
|
||||
|
||||
const mockWriteStream = {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === 'finish') setTimeout(cb, 10);
|
||||
return mockWriteStream;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
|
||||
const mockDataStream = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
pipe: vi.fn().mockReturnValue(mockWriteStream),
|
||||
};
|
||||
axiosMock.mockResolvedValue({
|
||||
data: mockDataStream,
|
||||
headers: { 'content-length': '500000' },
|
||||
});
|
||||
|
||||
fsMock.stat.mockResolvedValue({ size: 500000 });
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-2',
|
||||
audiobookId: 'ab-2',
|
||||
audiobook: { id: 'ab-2' },
|
||||
});
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processStartDirectDownload({
|
||||
requestId: 'req-2',
|
||||
downloadHistoryId: 'dh-2',
|
||||
downloadUrl: 'https://slow.example.com/book2',
|
||||
targetFilename: 'Another Book.epub',
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-2',
|
||||
'ab-2',
|
||||
expect.stringContaining('Another Book.epub')
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request as failed when all download attempts fail', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
torrentUrl: JSON.stringify([
|
||||
'https://slow1.example.com/book',
|
||||
'https://slow2.example.com/book',
|
||||
]),
|
||||
});
|
||||
|
||||
// All extract attempts fail
|
||||
ebookScraperMock.extractDownloadUrl.mockResolvedValue(null);
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processStartDirectDownload({
|
||||
requestId: 'req-3',
|
||||
downloadHistoryId: 'dh-3',
|
||||
downloadUrl: 'https://slow1.example.com/book',
|
||||
targetFilename: 'Failed Book.epub',
|
||||
jobId: 'job-3',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Verify the second call (final failure status update)
|
||||
expect(prismaMock.request.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'req-3' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
}),
|
||||
});
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'dh-3' },
|
||||
data: expect.objectContaining({
|
||||
downloadStatus: 'failed',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses FlareSolverr when configured', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
|
||||
});
|
||||
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'downloads_dir') return '/downloads';
|
||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
|
||||
return null;
|
||||
});
|
||||
|
||||
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
|
||||
url: 'https://direct.example.com/book.epub',
|
||||
format: 'epub',
|
||||
});
|
||||
|
||||
const mockWriteStream = {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === 'finish') setTimeout(cb, 10);
|
||||
return mockWriteStream;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
|
||||
const mockDataStream = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
pipe: vi.fn().mockReturnValue(mockWriteStream),
|
||||
};
|
||||
axiosMock.mockResolvedValue({
|
||||
data: mockDataStream,
|
||||
headers: { 'content-length': '500000' },
|
||||
});
|
||||
|
||||
fsMock.stat.mockResolvedValue({ size: 500000 });
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-4',
|
||||
audiobookId: 'ab-4',
|
||||
audiobook: { id: 'ab-4' },
|
||||
});
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
await processStartDirectDownload({
|
||||
requestId: 'req-4',
|
||||
downloadHistoryId: 'dh-4',
|
||||
downloadUrl: 'https://slow.example.com/book',
|
||||
targetFilename: 'Flare Book.epub',
|
||||
jobId: 'job-4',
|
||||
});
|
||||
|
||||
expect(ebookScraperMock.extractDownloadUrl).toHaveBeenCalledWith(
|
||||
'https://slow.example.com/book',
|
||||
'https://annas-archive.li',
|
||||
'epub',
|
||||
expect.anything(),
|
||||
'http://flaresolverr:8191'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles errors and updates request status', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
await expect(processStartDirectDownload({
|
||||
requestId: 'req-5',
|
||||
downloadHistoryId: 'dh-5',
|
||||
downloadUrl: 'https://slow.example.com/book',
|
||||
targetFilename: 'Error Book.epub',
|
||||
jobId: 'job-5',
|
||||
})).rejects.toThrow('Database error');
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-5' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
errorMessage: 'Database error',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processMonitorDirectDownload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns completed status when download file exists', async () => {
|
||||
fsMock.stat.mockResolvedValue({ size: 1000000 });
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMonitorDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processMonitorDirectDownload({
|
||||
requestId: 'req-m1',
|
||||
downloadHistoryId: 'dh-m1',
|
||||
downloadId: 'dl_unknown',
|
||||
targetPath: '/downloads/book.epub',
|
||||
expectedSize: 1000000,
|
||||
jobId: 'job-m1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.completed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns not found when download is not tracked', async () => {
|
||||
fsMock.stat.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const { processMonitorDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processMonitorDirectDownload({
|
||||
requestId: 'req-m2',
|
||||
downloadHistoryId: 'dh-m2',
|
||||
downloadId: 'dl_missing',
|
||||
targetPath: '/downloads/missing.epub',
|
||||
expectedSize: 500000,
|
||||
jobId: 'job-m2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,12 @@ vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
describe('processOrganizeFiles', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock for request lookup (processor needs to determine request type)
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-default',
|
||||
type: 'audiobook', // Default to audiobook type
|
||||
user: { plexUsername: 'testuser' },
|
||||
});
|
||||
});
|
||||
|
||||
it('organizes files and triggers filesystem scan when enabled', async () => {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Component: Search Ebook Processor Tests
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addStartDirectDownloadJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const ebookScraperMock = vi.hoisted(() => ({
|
||||
searchByAsin: vi.fn(),
|
||||
searchByTitle: vi.fn(),
|
||||
getSlowDownloadLinks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock);
|
||||
|
||||
describe('processSearchEbook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('searches by ASIN when available and triggers download', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue('abc123md5');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
|
||||
'https://slow1.example.com/abc123',
|
||||
'https://slow2.example.com/abc123',
|
||||
]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-1',
|
||||
audiobook: {
|
||||
id: 'ab-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
asin: 'B001ASIN',
|
||||
},
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('ASIN');
|
||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
||||
'B001ASIN',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
expect.anything(),
|
||||
undefined
|
||||
);
|
||||
expect(jobQueueMock.addStartDirectDownloadJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'dh-1',
|
||||
'https://slow1.example.com/abc123',
|
||||
'Test Book - Test Author.epub',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to title search when ASIN search fails', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue(null);
|
||||
ebookScraperMock.searchByTitle.mockResolvedValue('xyz789md5');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
|
||||
'https://slow1.example.com/xyz789',
|
||||
]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-2',
|
||||
audiobook: {
|
||||
id: 'ab-2',
|
||||
title: 'Another Book',
|
||||
author: 'Another Author',
|
||||
asin: 'B002ASIN',
|
||||
},
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('title search');
|
||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalled();
|
||||
expect(ebookScraperMock.searchByTitle).toHaveBeenCalledWith(
|
||||
'Another Book',
|
||||
'Another Author',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
expect.anything(),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('searches by title when no ASIN is available', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-3' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByTitle.mockResolvedValue('noasin123');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
|
||||
'https://slow.example.com/noasin123',
|
||||
]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-3',
|
||||
audiobook: {
|
||||
id: 'ab-3',
|
||||
title: 'No ASIN Book',
|
||||
author: 'No ASIN Author',
|
||||
// No asin field
|
||||
},
|
||||
jobId: 'job-3',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(ebookScraperMock.searchByAsin).not.toHaveBeenCalled();
|
||||
expect(ebookScraperMock.searchByTitle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks request as awaiting_search when no ebook found', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue(null);
|
||||
ebookScraperMock.searchByTitle.mockResolvedValue(null);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-4',
|
||||
audiobook: {
|
||||
id: 'ab-4',
|
||||
title: 'Unfindable Book',
|
||||
author: 'Unknown Author',
|
||||
asin: 'B004ASIN',
|
||||
},
|
||||
jobId: 'job-4',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('re-search');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-4' },
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_search',
|
||||
errorMessage: expect.stringContaining('No ebook found'),
|
||||
}),
|
||||
});
|
||||
expect(jobQueueMock.addStartDirectDownloadJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks request as awaiting_search when no download links available', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue('md5nolinks');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-5',
|
||||
audiobook: {
|
||||
id: 'ab-5',
|
||||
title: 'Book No Links',
|
||||
author: 'Author No Links',
|
||||
asin: 'B005ASIN',
|
||||
},
|
||||
jobId: 'job-5',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('re-search');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-5' },
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_search',
|
||||
errorMessage: expect.stringContaining('no download links'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses FlareSolverr when configured', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-6' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
|
||||
return null;
|
||||
});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue('md5withflare');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue(['https://slow.example.com/flare']);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
await processSearchEbook({
|
||||
requestId: 'req-6',
|
||||
audiobook: {
|
||||
id: 'ab-6',
|
||||
title: 'Flare Book',
|
||||
author: 'Flare Author',
|
||||
asin: 'B006ASIN',
|
||||
},
|
||||
jobId: 'job-6',
|
||||
});
|
||||
|
||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
||||
'B006ASIN',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
expect.anything(),
|
||||
'http://flaresolverr:8191'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails request on unexpected errors', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
await expect(processSearchEbook({
|
||||
requestId: 'req-7',
|
||||
audiobook: {
|
||||
id: 'ab-7',
|
||||
title: 'Error Book',
|
||||
author: 'Error Author',
|
||||
asin: 'B007ASIN',
|
||||
},
|
||||
jobId: 'job-7',
|
||||
})).rejects.toThrow('Network error');
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-7' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
errorMessage: 'Network error',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates download history with correct metadata', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-8' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue('md5metadata');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
|
||||
'https://link1.example.com',
|
||||
'https://link2.example.com',
|
||||
]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
await processSearchEbook({
|
||||
requestId: 'req-8',
|
||||
audiobook: {
|
||||
id: 'ab-8',
|
||||
title: 'Metadata Book',
|
||||
author: 'Metadata Author',
|
||||
asin: 'B008ASIN',
|
||||
},
|
||||
jobId: 'job-8',
|
||||
});
|
||||
|
||||
expect(prismaMock.downloadHistory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
requestId: 'req-8',
|
||||
indexerName: "Anna's Archive",
|
||||
torrentName: 'Metadata Book - Metadata Author.epub',
|
||||
downloadClient: 'direct',
|
||||
downloadStatus: 'queued',
|
||||
selected: true,
|
||||
qualityScore: 100, // ASIN match = 100
|
||||
}),
|
||||
});
|
||||
|
||||
// Check that all URLs are stored
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dh-8' },
|
||||
data: {
|
||||
torrentUrl: JSON.stringify([
|
||||
'https://link1.example.com',
|
||||
'https://link2.example.com',
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user