mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Use .gl for Anna's Archive; add manual-import test
Replace default Anna's Archive base URL from https://annas-archive.li to https://annas-archive.gl across docs, UI components, API routes, processors, services, and tests. Add comprehensive tests for the admin manual-import API route and enhance the manual-import route to fetch missing ASIN details from Audnexus and create audiobook records with proper error handling and logging. Update related test expectations and FlareSolverr test usages to reflect the new default URL.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Component: Admin Manual Import API Route Tests
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
let requestBody: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addOrganizeJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
getAudiobookDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
// fs mock
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
AuthenticatedRequest: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => fsMock);
|
||||
|
||||
vi.mock('path', async () => {
|
||||
const actual = await vi.importActual<typeof import('path')>('path');
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
resolve: (...args: string[]) => actual.posix.resolve(...args),
|
||||
extname: actual.posix.extname,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Admin manual-import route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requestBody = { asin: 'B00TEST0001', folderPath: '/bookdrop/author/title' };
|
||||
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
|
||||
// Default: download_dir and media_dir not configured, bookdrop exists
|
||||
prismaMock.configuration.findUnique.mockResolvedValue(null);
|
||||
fsMock.stat.mockImplementation(async (p: string) => {
|
||||
if (p === '/bookdrop') return { isDirectory: () => true };
|
||||
if (p === '/bookdrop/author/title') return { isDirectory: () => true };
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
fsMock.readdir.mockResolvedValue([
|
||||
{ name: 'chapter1.m4b', isFile: () => true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates audiobook from Audnexus when ASIN is not in DB or cache', async () => {
|
||||
// Neither audiobook nor audibleCache has this ASIN
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
// Audnexus returns live data
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
|
||||
asin: 'B00TEST0001',
|
||||
title: 'Live Title',
|
||||
author: 'Live Author',
|
||||
coverArtUrl: 'https://example.com/cover.jpg',
|
||||
narrator: 'Live Narrator',
|
||||
series: 'Test Series',
|
||||
seriesPart: '1',
|
||||
seriesAsin: 'SERIES0001',
|
||||
releaseDate: '2024-01-15',
|
||||
});
|
||||
|
||||
// audiobook.create returns the new record
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||
id: 'ab-new',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Live Title',
|
||||
author: 'Live Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// audiobook.findUnique for the verification step
|
||||
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
|
||||
id: 'ab-new',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Live Title',
|
||||
author: 'Live Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// No existing request
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-new' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(audibleServiceMock.getAudiobookDetails).toHaveBeenCalledWith('B00TEST0001');
|
||||
expect(prismaMock.audiobook.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Live Title',
|
||||
author: 'Live Author',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 when ASIN is not in DB, cache, or Audnexus', async () => {
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Audiobook not found for the given ASIN');
|
||||
});
|
||||
|
||||
it('returns 404 when Audnexus lookup throws an error', async () => {
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
|
||||
audibleServiceMock.getAudiobookDetails.mockRejectedValueOnce(new Error('Network timeout'));
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Audiobook not found for the given ASIN');
|
||||
});
|
||||
|
||||
it('uses existing audiobook record when ASIN is in DB', async () => {
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce({
|
||||
id: 'ab-existing',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
});
|
||||
|
||||
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
|
||||
id: 'ab-existing',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Existing Title',
|
||||
author: 'Existing Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
// Should NOT have queried audibleCache for ASIN resolution
|
||||
expect(prismaMock.audibleCache.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses audibleCache when ASIN is not in audiobook table but is cached', async () => {
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
||||
asin: 'B00TEST0001',
|
||||
title: 'Cached Title',
|
||||
author: 'Cached Author',
|
||||
coverArtUrl: 'https://example.com/cached.jpg',
|
||||
narrator: 'Cached Narrator',
|
||||
});
|
||||
|
||||
// audiobook.create from cache
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||
id: 'ab-from-cache',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Cached Title',
|
||||
author: 'Cached Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
|
||||
id: 'ab-from-cache',
|
||||
audibleAsin: 'B00TEST0001',
|
||||
title: 'Cached Title',
|
||||
author: 'Cached Author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-2' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/manual-import/route');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue(requestBody),
|
||||
nextUrl: new URL('http://localhost/api/admin/manual-import'),
|
||||
};
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
// audiobook.create should have used cache data, not Audnexus
|
||||
expect(prismaMock.audiobook.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
title: 'Cached Title',
|
||||
author: 'Cached Author',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -355,7 +355,7 @@ describe('Admin settings core routes', () => {
|
||||
|
||||
it('updates ebook settings', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.li' }),
|
||||
json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.gl' }),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/ebook/route');
|
||||
|
||||
@@ -348,14 +348,14 @@ describe('Admin settings test routes', () => {
|
||||
|
||||
it('tests FlareSolverr connection', async () => {
|
||||
testFlareSolverrMock.mockResolvedValueOnce({ success: true });
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.gl' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.li');
|
||||
expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.gl');
|
||||
});
|
||||
|
||||
it('rejects FlareSolverr test when URL is missing', async () => {
|
||||
@@ -382,7 +382,7 @@ describe('Admin settings test routes', () => {
|
||||
|
||||
it('returns error when FlareSolverr test throws', async () => {
|
||||
testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down'));
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.gl' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
||||
const response = await POST(request as any);
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('RequestActionsDropdown', () => {
|
||||
author: 'Author',
|
||||
status: 'downloaded',
|
||||
type: 'ebook',
|
||||
torrentUrl: JSON.stringify(['https://annas-archive.li/slow_download/abc123def456abc123def456abc123de/0/5']),
|
||||
torrentUrl: JSON.stringify(['https://annas-archive.gl/slow_download/abc123def456abc123def456abc123de/0/5']),
|
||||
}}
|
||||
onManualSearch={vi.fn().mockResolvedValue(undefined)}
|
||||
onCancel={vi.fn().mockResolvedValue(undefined)}
|
||||
|
||||
@@ -28,7 +28,7 @@ const renderHook = <T,>(hook: () => T) => {
|
||||
const baseEbook = {
|
||||
enabled: true,
|
||||
preferredFormat: 'epub',
|
||||
baseUrl: 'https://annas-archive.li',
|
||||
baseUrl: 'https://annas-archive.gl',
|
||||
flaresolverrUrl: 'http://flare',
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('useEbookSettings', () => {
|
||||
expect(result.current.flaresolverrTestResult?.success).toBe(true);
|
||||
// Verify baseUrl is included in the request body
|
||||
const callBody = JSON.parse(fetchWithAuthMock.mock.calls[0][1].body);
|
||||
expect(callBody.baseUrl).toBe('https://annas-archive.li');
|
||||
expect(callBody.baseUrl).toBe('https://annas-archive.gl');
|
||||
expect(callBody.url).toBe('http://flare');
|
||||
});
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('IndexersTab - Auto-load Indexers on Tab Activation', () => {
|
||||
ebook: {
|
||||
enabled: false,
|
||||
preferredFormat: 'epub',
|
||||
baseUrl: 'https://annas-archive.li',
|
||||
baseUrl: 'https://annas-archive.gl',
|
||||
flaresolverrUrl: '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('processStartDirectDownload', () => {
|
||||
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_base_url') return 'https://annas-archive.gl';
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
return null;
|
||||
});
|
||||
@@ -238,7 +238,7 @@ describe('processStartDirectDownload', () => {
|
||||
|
||||
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_base_url') return 'https://annas-archive.gl';
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
|
||||
return null;
|
||||
@@ -286,7 +286,7 @@ describe('processStartDirectDownload', () => {
|
||||
|
||||
expect(ebookScraperMock.extractDownloadUrl).toHaveBeenCalledWith(
|
||||
'https://slow.example.com/book',
|
||||
'https://annas-archive.li',
|
||||
'https://annas-archive.gl',
|
||||
'epub',
|
||||
expect.anything(),
|
||||
'http://flaresolverr:8191'
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('processSearchEbook', () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
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_base_url') return 'https://annas-archive.gl';
|
||||
if (key === 'ebook_annas_archive_enabled') return 'true';
|
||||
if (key === 'ebook_indexer_search_enabled') return 'false';
|
||||
return null;
|
||||
@@ -79,7 +79,7 @@ describe('processSearchEbook', () => {
|
||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
||||
'B001ASIN',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
'https://annas-archive.gl',
|
||||
expect.anything(),
|
||||
undefined,
|
||||
'en'
|
||||
@@ -124,7 +124,7 @@ describe('processSearchEbook', () => {
|
||||
'Another Book',
|
||||
'Another Author',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
'https://annas-archive.gl',
|
||||
expect.anything(),
|
||||
undefined,
|
||||
'en'
|
||||
@@ -229,7 +229,7 @@ describe('processSearchEbook', () => {
|
||||
|
||||
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_base_url') return 'https://annas-archive.gl';
|
||||
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';
|
||||
@@ -255,7 +255,7 @@ describe('processSearchEbook', () => {
|
||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
||||
'B006ASIN',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
'https://annas-archive.gl',
|
||||
expect.anything(),
|
||||
'http://flaresolverr:8191',
|
||||
'en'
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('E-book sidecar', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.responseTime).toBeTypeOf('number');
|
||||
@@ -95,7 +95,7 @@ describe('E-book sidecar', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
@@ -103,7 +103,7 @@ describe('E-book sidecar', () => {
|
||||
it('returns error details when FlareSolverr request fails', async () => {
|
||||
axiosMock.post.mockRejectedValue(new Error('flare down'));
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('flare down');
|
||||
@@ -117,7 +117,7 @@ describe('E-book sidecar', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('FlareSolverr error');
|
||||
@@ -132,7 +132,7 @@ describe('E-book sidecar', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
|
||||
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('FlareSolverr returned HTTP 403');
|
||||
@@ -221,7 +221,7 @@ describe('E-book sidecar', () => {
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN-NO', 'Title', 'Author', '/downloads', 'pdf', 'https://annas-archive.li', undefined, 'http://flare');
|
||||
const promise = downloadEbook('ASIN-NO', 'Title', 'Author', '/downloads', 'pdf', 'https://annas-archive.gl', undefined, 'http://flare');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
@@ -417,7 +417,7 @@ describe('E-book sidecar', () => {
|
||||
'Author',
|
||||
'/downloads',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
'https://annas-archive.gl',
|
||||
undefined,
|
||||
'http://flare'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user