mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge branch 'ebook-piecewise'
This commit is contained in:
@@ -20,6 +20,7 @@ const jobQueueMock = vi.hoisted(() => ({
|
||||
addSearchJob: vi.fn(),
|
||||
addDownloadJob: vi.fn(),
|
||||
addNotificationJob: vi.fn(() => Promise.resolve()),
|
||||
addSearchEbookJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const downloadEbookMock = vi.hoisted(() => vi.fn());
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
@@ -355,42 +356,75 @@ describe('Request action routes', () => {
|
||||
expect(payload.error).toMatch(/Cannot fetch e-book/);
|
||||
});
|
||||
|
||||
it('returns 400 when audiobook directory is missing', async () => {
|
||||
it('creates ebook request and triggers search job', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
|
||||
// Mock parent request lookup
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
userId: 'user-1',
|
||||
audiobookId: 'ab-1',
|
||||
status: 'downloaded',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
});
|
||||
|
||||
// Mock check for existing ebook request
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
// Mock ebook request creation
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'ebook-req-1',
|
||||
type: 'ebook',
|
||||
parentRequestId: 'req-6',
|
||||
});
|
||||
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/);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toMatch(/created/i);
|
||||
expect(payload.requestId).toBe('ebook-req-1');
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
type: 'ebook',
|
||||
parentRequestId: 'req-6',
|
||||
status: 'pending',
|
||||
}),
|
||||
});
|
||||
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalledWith(
|
||||
'ebook-req-1',
|
||||
expect.objectContaining({
|
||||
id: 'ab-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
asin: 'ASIN123',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads ebook and returns success', async () => {
|
||||
it('retries existing failed ebook request', 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');
|
||||
|
||||
// Mock parent request lookup
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
userId: 'user-1',
|
||||
audiobookId: 'ab-1',
|
||||
status: 'available',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
audiobook: { id: 'ab-1', 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',
|
||||
|
||||
// Mock existing failed ebook request
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'ebook-req-existing',
|
||||
status: 'failed',
|
||||
});
|
||||
|
||||
// Mock update for retry
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'ebook-req-existing',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
@@ -398,29 +432,35 @@ describe('Request action routes', () => {
|
||||
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'
|
||||
);
|
||||
expect(payload.message).toMatch(/retried/i);
|
||||
expect(payload.requestId).toBe('ebook-req-existing');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'ebook-req-existing' },
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
}),
|
||||
});
|
||||
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns failure payload when ebook download fails', async () => {
|
||||
it('returns message when ebook request already exists and in progress', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
|
||||
// Mock parent request lookup
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-8',
|
||||
userId: 'user-1',
|
||||
audiobookId: 'ab-1',
|
||||
status: 'downloaded',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
});
|
||||
fsMock.access.mockResolvedValueOnce(undefined);
|
||||
downloadEbookMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Download failed',
|
||||
|
||||
// Mock existing in-progress ebook request
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'ebook-req-existing',
|
||||
status: 'downloading',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
@@ -428,7 +468,11 @@ describe('Request action routes', () => {
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(false);
|
||||
expect(payload.message).toMatch(/Download failed/);
|
||||
expect(payload.message).toMatch(/already exists/i);
|
||||
expect(payload.requestId).toBe('ebook-req-existing');
|
||||
// Should not create new request or trigger search
|
||||
expect(prismaMock.request.create).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,10 @@ const processorsMock = vi.hoisted(() => ({
|
||||
processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'),
|
||||
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
||||
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
||||
// Ebook processors
|
||||
processSearchEbook: vi.fn().mockResolvedValue('ok'),
|
||||
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
|
||||
processMonitorDirectDownload: vi.fn().mockResolvedValue('ok'),
|
||||
}));
|
||||
|
||||
const queueMock = vi.hoisted(() => ({
|
||||
@@ -111,6 +115,16 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({
|
||||
processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents,
|
||||
}));
|
||||
|
||||
// Ebook processors
|
||||
vi.mock('@/lib/processors/search-ebook.processor', () => ({
|
||||
processSearchEbook: processorsMock.processSearchEbook,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/direct-download.processor', () => ({
|
||||
processStartDirectDownload: processorsMock.processStartDirectDownload,
|
||||
processMonitorDirectDownload: processorsMock.processMonitorDirectDownload,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
@@ -56,6 +56,9 @@ vi.mock('@/lib/utils/file-organizer', () => ({
|
||||
describe('deleteRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock for child request queries (audiobook requests check for child ebook requests)
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.updateMany.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
it('returns not found when request is missing', async () => {
|
||||
|
||||
@@ -275,17 +275,8 @@ describe('file organizer', () => {
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
});
|
||||
|
||||
it('downloads remote cover art and ebook sidecar when enabled', async () => {
|
||||
it('downloads remote cover art when no local cover exists', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
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');
|
||||
|
||||
ebookMock.downloadEbook.mockResolvedValue({
|
||||
success: true,
|
||||
filePath: '/media/Author/Book/book.epub',
|
||||
});
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
@@ -322,18 +313,11 @@ describe('file organizer', () => {
|
||||
'https://images.example/cover.jpg',
|
||||
expect.objectContaining({ responseType: 'arraybuffer' })
|
||||
);
|
||||
expect(ebookMock.downloadEbook).toHaveBeenCalledWith(
|
||||
'ASIN123',
|
||||
'Book',
|
||||
'Author',
|
||||
expectedDir,
|
||||
'epub',
|
||||
'https://ebooks.example',
|
||||
undefined,
|
||||
'http://flaresolverr'
|
||||
);
|
||||
// NOTE: Ebook downloads are now handled as first-class requests through the job queue
|
||||
// The file organizer no longer downloads ebooks inline
|
||||
expect(ebookMock.downloadEbook).not.toHaveBeenCalled();
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
expect(result.filesMovedCount).toBe(2);
|
||||
expect(result.filesMovedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('records an error when cover art download fails', async () => {
|
||||
@@ -444,36 +428,9 @@ describe('file organizer', () => {
|
||||
expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata');
|
||||
});
|
||||
|
||||
it('records ebook sidecar errors when download throws', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
|
||||
ebookMock.downloadEbook.mockRejectedValue(new Error('ebook down'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toContain('E-book sidecar failed');
|
||||
});
|
||||
// NOTE: The ebook sidecar test was removed because ebook downloads are now
|
||||
// handled as first-class requests through the job queue, not inline during
|
||||
// file organization. See organize-files.processor.ts createEbookRequestIfEnabled().
|
||||
|
||||
it('finds audio files and cover art in nested folders', async () => {
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
|
||||
@@ -1034,6 +1034,159 @@ describe('ranking-algorithm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initial Variations (J.N. vs J N)', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches "J.N. Chaney" to torrent with "J N Chaney" in automatic mode', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Infinite Crown by Terry Maggert, J N Chaney [ENG / M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Infinite Crown',
|
||||
author: 'J.N. Chaney',
|
||||
}, true); // requireAuthor: true (automatic mode)
|
||||
|
||||
// "J.N. Chaney" should normalize to "j n chaney"
|
||||
// Torrent title should normalize to include "j n chaney"
|
||||
// Author check should PASS
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
expect(breakdown.totalScore).toBeGreaterThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('matches author with periods to space-separated initials', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title by J K Rowling [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'J.K. Rowling',
|
||||
}, true);
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CamelCase and Punctuation Separator Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches CamelCase torrent title "VirginaEvans TheCorrespondent" to "The Correspondent" by "Virginia Evans"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'VirginaEvans TheCorrespondent',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Correspondent',
|
||||
author: 'Virginia Evans',
|
||||
}, false); // requireAuthor: false - source has typo "Virgina" vs "Virginia"
|
||||
|
||||
// Should match after CamelCase normalization
|
||||
// "VirginaEvans TheCorrespondent" → "virgina evans the correspondent"
|
||||
// "The Correspondent" → "the correspondent" → required words: ["correspondent"]
|
||||
// Coverage: "correspondent" found → passes
|
||||
// Note: Author has typo in source data ("Virgina" vs "Virginia"), so fuzzy matching gives partial credit
|
||||
expect(breakdown.matchScore).toBeGreaterThan(35);
|
||||
});
|
||||
|
||||
it('matches period-separated title "Twelve.Months-Jim.Butcher" to "Twelve Months" by "Jim Butcher"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Twelve.Months-Jim.Butcher',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Twelve Months',
|
||||
author: 'Jim Butcher',
|
||||
});
|
||||
|
||||
// Should match after punctuation normalization
|
||||
// "Twelve.Months-Jim.Butcher" → "twelve months jim butcher"
|
||||
// Full title match + author match
|
||||
expect(breakdown.matchScore).toBeGreaterThan(55);
|
||||
});
|
||||
|
||||
it('matches mixed CamelCase and punctuation "AuthorName-BookTitle.2024"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'JohnSmith-GreatBook.2024',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Great Book',
|
||||
author: 'John Smith',
|
||||
});
|
||||
|
||||
// "JohnSmith-GreatBook.2024" → "john smith great book 2024"
|
||||
// Gets good fuzzy match score (title words present, author present)
|
||||
expect(breakdown.matchScore).toBeGreaterThan(35);
|
||||
});
|
||||
|
||||
it('matches CamelCase author with no separator "AuthorNameBookTitle"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'BrandonSandersonMistborn',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Mistborn',
|
||||
author: 'Brandon Sanderson',
|
||||
});
|
||||
|
||||
// "BrandonSandersonMistborn" → "brandon sanderson mistborn"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('handles underscore separators "Author_Name_Book_Title"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Jane_Doe_Amazing_Story',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Amazing Story',
|
||||
author: 'Jane Doe',
|
||||
});
|
||||
|
||||
// "Jane_Doe_Amazing_Story" → "jane doe amazing story"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('preserves apostrophes in names like "O\'Brien"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: "Tim O'Brien - The Things They Carried",
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Things They Carried',
|
||||
author: "Tim O'Brien",
|
||||
});
|
||||
|
||||
// Apostrophe should be preserved
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('handles real-world NZB title format with periods', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Berlin Diary',
|
||||
author: 'William L. Shirer',
|
||||
});
|
||||
|
||||
// "William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs" → "william l shirer berlin diary audiobook 96kbs"
|
||||
// Gets partial score from fuzzy matching (title words + author words present)
|
||||
expect(breakdown.matchScore).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy API Compatibility', () => {
|
||||
it('supports legacy rankTorrents signature with separate parameters', () => {
|
||||
const torrent = {
|
||||
|
||||
Reference in New Issue
Block a user