Files
ReadMeABook/tests/processors/search-ebook.processor.test.ts
T
kikootwo c559f8ebe9 SABnzbd path mapping + ASIN-based request deletion
Add bidirectional path mapping and complete_dir-aware category sync to the SABnzbd integration. Introduces PathMapper usage, complete_dir extraction, calculateCategoryPath(), and ensureCategory() logic to choose empty/relative/absolute category paths; ensureCategory is invoked before adding NZBs. Update singleton factory to load download_dir and path-mapping config from DownloadClientManager and recreate the service when config is not loaded. Make DownloadClientManager pass path-mapping config into the SABnzbd service. Change request deletion to remove plex_library records by ASIN (deleteMany) with a fallback to exact title/author matches so availability checks and deletions are consistent. Update documentation and tests to reflect the new behavior and APIs.
2026-02-03 12:20:44 -05:00

335 lines
10 KiB
TypeScript

/**
* 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';
if (key === 'ebook_annas_archive_enabled') return 'true';
if (key === 'ebook_indexer_search_enabled') return 'false';
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("Anna's Archive");
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("Anna's Archive");
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'),
lastSearchAt: expect.any(Date),
}),
});
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 ebook found'),
lastSearchAt: expect.any(Date),
}),
});
});
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';
if (key === 'ebook_annas_archive_enabled') return 'true';
if (key === 'ebook_indexer_search_enabled') return 'false';
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',
]),
},
});
});
});