mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
590f089733
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.
417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
/**
|
|
* Component: Request Delete Service Tests
|
|
* Documentation: documentation/admin-features/request-deletion.md
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import path from 'path';
|
|
import { createPrismaMock } from '../helpers/prisma';
|
|
|
|
const prismaMock = createPrismaMock();
|
|
const fsMock = {
|
|
access: vi.fn(),
|
|
rm: vi.fn(),
|
|
};
|
|
const configServiceMock = {
|
|
get: vi.fn(),
|
|
getBackendMode: vi.fn(),
|
|
};
|
|
const qbtMock = {
|
|
getTorrent: vi.fn(),
|
|
deleteTorrent: vi.fn(),
|
|
};
|
|
const sabMock = {
|
|
deleteNZB: vi.fn(),
|
|
};
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
vi.mock('fs/promises', () => fsMock);
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: () => configServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
|
getQBittorrentService: async () => qbtMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
|
getSABnzbdService: async () => sabMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
|
deleteABSItem: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/utils/file-organizer', () => ({
|
|
buildAudiobookPath: vi.fn((mediaDir: string, template: string, data: any) => {
|
|
// Simple mock implementation that mimics the real behavior for tests
|
|
return path.join(mediaDir, data.author, `${data.title} ${data.asin}`);
|
|
}),
|
|
}));
|
|
|
|
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 () => {
|
|
prismaMock.request.findFirst.mockResolvedValue(null);
|
|
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
|
|
|
const result = await deleteRequest('req-1', 'admin-1');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('NotFound');
|
|
});
|
|
|
|
it('deletes completed qBittorrent downloads when seeding requirement met', async () => {
|
|
prismaMock.request.findFirst.mockResolvedValue({
|
|
id: 'req-1',
|
|
audiobook: {
|
|
id: 'ab-1',
|
|
title: 'Book',
|
|
author: 'Author',
|
|
audibleAsin: 'ASIN1',
|
|
plexGuid: 'plex-1',
|
|
absItemId: null,
|
|
},
|
|
downloadHistory: [
|
|
{
|
|
torrentHash: 'hash-1',
|
|
indexerName: 'IndexerA',
|
|
downloadStatus: 'completed',
|
|
},
|
|
],
|
|
});
|
|
configServiceMock.get.mockImplementation(async (key: string) => {
|
|
if (key === 'prowlarr_indexers') {
|
|
return JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 1 }]);
|
|
}
|
|
if (key === 'media_dir') {
|
|
return '/media';
|
|
}
|
|
if (key === 'audiobook_path_template') {
|
|
return '{author}/{title} {asin}';
|
|
}
|
|
return null;
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
|
qbtMock.getTorrent.mockResolvedValue({
|
|
name: 'Book',
|
|
seeding_time: 120,
|
|
});
|
|
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
|
releaseDate: '2021-01-01T00:00:00.000Z',
|
|
});
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{ id: 'lib-1', title: 'Book', author: 'Author' },
|
|
]);
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.rm.mockResolvedValue(undefined);
|
|
prismaMock.request.update.mockResolvedValue({});
|
|
prismaMock.audiobook.update.mockResolvedValue({});
|
|
|
|
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
|
const result = await deleteRequest('req-1', 'admin-1');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.torrentsRemoved).toBe(1);
|
|
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
|
|
expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'lib-1' } });
|
|
|
|
const expectedPath = path.join('/media', 'Author', 'Book ASIN1');
|
|
expect(fsMock.rm).toHaveBeenCalledWith(expectedPath, { recursive: true, force: true });
|
|
});
|
|
|
|
it('removes SABnzbd downloads and continues cleanup', async () => {
|
|
prismaMock.request.findFirst.mockResolvedValue({
|
|
id: 'req-2',
|
|
audiobook: {
|
|
id: 'ab-2',
|
|
title: 'Book Two',
|
|
author: 'Author',
|
|
audibleAsin: null,
|
|
plexGuid: 'plex-2',
|
|
absItemId: null,
|
|
},
|
|
downloadHistory: [
|
|
{
|
|
nzbId: 'nzb-1',
|
|
indexerName: 'IndexerB',
|
|
downloadStatus: 'completed',
|
|
},
|
|
],
|
|
});
|
|
configServiceMock.get.mockImplementation(async (key: string) => {
|
|
if (key === 'prowlarr_indexers') {
|
|
return JSON.stringify([{ name: 'IndexerB', seedingTimeMinutes: 0 }]);
|
|
}
|
|
if (key === 'media_dir') {
|
|
return '/media';
|
|
}
|
|
return null;
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
|
sabMock.deleteNZB.mockResolvedValue(undefined);
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.rm.mockResolvedValue(undefined);
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
|
prismaMock.request.update.mockResolvedValue({});
|
|
prismaMock.audiobook.update.mockResolvedValue({});
|
|
|
|
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
|
const result = await deleteRequest('req-2', 'admin-1');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.torrentsRemoved).toBe(1);
|
|
expect(sabMock.deleteNZB).toHaveBeenCalledWith('nzb-1', true);
|
|
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ deletedBy: 'admin-1' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('keeps torrents seeding when requirement is not met', async () => {
|
|
prismaMock.request.findFirst.mockResolvedValue({
|
|
id: 'req-3',
|
|
audiobook: {
|
|
id: 'ab-3',
|
|
title: 'Book Three',
|
|
author: 'Author Name',
|
|
audibleAsin: 'ASIN3',
|
|
plexGuid: 'plex-3',
|
|
absItemId: null,
|
|
},
|
|
downloadHistory: [
|
|
{
|
|
torrentHash: 'hash-3',
|
|
indexerName: 'IndexerC',
|
|
downloadStatus: 'completed',
|
|
},
|
|
],
|
|
});
|
|
configServiceMock.get.mockImplementation(async (key: string) => {
|
|
if (key === 'prowlarr_indexers') {
|
|
return JSON.stringify([{ name: 'IndexerC', seedingTimeMinutes: 10 }]);
|
|
}
|
|
if (key === 'media_dir') {
|
|
return '/media';
|
|
}
|
|
if (key === 'audiobook_path_template') {
|
|
return '{author}/{title} {asin}';
|
|
}
|
|
return null;
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
|
qbtMock.getTorrent.mockResolvedValue({
|
|
name: 'Book Three',
|
|
seeding_time: 60,
|
|
});
|
|
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
|
releaseDate: '2020-01-01T00:00:00.000Z',
|
|
});
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{ id: 'lib-2', title: 'Book Three', author: 'Other' },
|
|
]);
|
|
fsMock.access
|
|
.mockRejectedValueOnce(new Error('missing'))
|
|
.mockResolvedValueOnce(undefined);
|
|
fsMock.rm.mockResolvedValue(undefined);
|
|
prismaMock.request.update.mockResolvedValue({});
|
|
prismaMock.audiobook.update.mockResolvedValue({});
|
|
|
|
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
|
const result = await deleteRequest('req-3', 'admin-2');
|
|
|
|
expect(result.torrentsKeptSeeding).toBe(1);
|
|
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
|
|
|
|
// Path doesn't exist, so rm should not be called (first access fails)
|
|
expect(fsMock.rm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps torrents for unlimited seeding when no config is present', async () => {
|
|
prismaMock.request.findFirst.mockResolvedValue({
|
|
id: 'req-4',
|
|
audiobook: {
|
|
id: 'ab-4',
|
|
title: 'Book Four',
|
|
author: 'Author',
|
|
audibleAsin: null,
|
|
plexGuid: 'plex-4',
|
|
absItemId: null,
|
|
},
|
|
downloadHistory: [
|
|
{
|
|
torrentHash: 'hash-4',
|
|
indexerName: 'IndexerD',
|
|
downloadStatus: 'completed',
|
|
},
|
|
],
|
|
});
|
|
configServiceMock.get.mockImplementation(async (key: string) => {
|
|
if (key === 'prowlarr_indexers') {
|
|
return null;
|
|
}
|
|
if (key === 'media_dir') {
|
|
return '/media';
|
|
}
|
|
return null;
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
|
qbtMock.getTorrent.mockResolvedValue({
|
|
name: 'Book Four',
|
|
seeding_time: 0,
|
|
});
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
|
fsMock.access.mockRejectedValue(new Error('missing'));
|
|
prismaMock.request.update.mockResolvedValue({});
|
|
prismaMock.audiobook.update.mockResolvedValue({});
|
|
|
|
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
|
const result = await deleteRequest('req-4', 'admin-3');
|
|
|
|
expect(result.torrentsKeptUnlimited).toBe(1);
|
|
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears audiobookshelf linkage when SABnzbd delete fails', async () => {
|
|
prismaMock.request.findFirst.mockResolvedValue({
|
|
id: 'req-5',
|
|
audiobook: {
|
|
id: 'ab-5',
|
|
title: 'Book Five',
|
|
author: 'Author',
|
|
audibleAsin: null,
|
|
plexGuid: null,
|
|
absItemId: 'abs-5',
|
|
},
|
|
downloadHistory: [
|
|
{
|
|
nzbId: 'nzb-5',
|
|
indexerName: 'IndexerE',
|
|
downloadStatus: 'completed',
|
|
},
|
|
],
|
|
});
|
|
configServiceMock.get.mockImplementation(async (key: string) => {
|
|
if (key === 'prowlarr_indexers') {
|
|
return JSON.stringify([{ name: 'IndexerE', seedingTimeMinutes: 0 }]);
|
|
}
|
|
if (key === 'media_dir') {
|
|
return '/media';
|
|
}
|
|
return null;
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
sabMock.deleteNZB.mockRejectedValue(new Error('missing'));
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
|
fsMock.access.mockRejectedValue(new Error('missing'));
|
|
prismaMock.request.update.mockResolvedValue({});
|
|
prismaMock.audiobook.update.mockResolvedValue({});
|
|
|
|
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
|
const result = await deleteRequest('req-5', 'admin-5');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(prismaMock.audiobook.update).toHaveBeenCalledWith({
|
|
where: { id: 'ab-5' },
|
|
data: expect.objectContaining({ absItemId: null }),
|
|
});
|
|
});
|
|
|
|
it('deletes library item from Audiobookshelf when backend is audiobookshelf', async () => {
|
|
prismaMock.request.findFirst.mockResolvedValue({
|
|
id: 'req-6',
|
|
audiobook: {
|
|
id: 'ab-6',
|
|
title: 'Book Six',
|
|
author: 'Author Six',
|
|
audibleAsin: 'ASIN6',
|
|
plexGuid: null,
|
|
absItemId: 'abs-item-123',
|
|
},
|
|
downloadHistory: [],
|
|
});
|
|
configServiceMock.get.mockImplementation(async (key: string) => {
|
|
if (key === 'media_dir') {
|
|
return '/media';
|
|
}
|
|
if (key === 'audiobook_path_template') {
|
|
return '{author}/{title} {asin}';
|
|
}
|
|
return null;
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
|
releaseDate: '2022-01-01T00:00:00.000Z',
|
|
});
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.rm.mockResolvedValue(undefined);
|
|
prismaMock.request.update.mockResolvedValue({});
|
|
prismaMock.audiobook.update.mockResolvedValue({});
|
|
|
|
const { deleteABSItem } = await import('@/lib/services/audiobookshelf/api');
|
|
vi.mocked(deleteABSItem).mockResolvedValue(undefined);
|
|
|
|
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
|
const result = await deleteRequest('req-6', 'admin-6');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(deleteABSItem).toHaveBeenCalledWith('abs-item-123');
|
|
expect(prismaMock.audiobook.update).toHaveBeenCalledWith({
|
|
where: { id: 'ab-6' },
|
|
data: expect.objectContaining({ absItemId: null }),
|
|
});
|
|
});
|
|
|
|
it('continues deletion even if Audiobookshelf item deletion fails', async () => {
|
|
prismaMock.request.findFirst.mockResolvedValue({
|
|
id: 'req-7',
|
|
audiobook: {
|
|
id: 'ab-7',
|
|
title: 'Book Seven',
|
|
author: 'Author Seven',
|
|
audibleAsin: null,
|
|
plexGuid: null,
|
|
absItemId: 'abs-item-456',
|
|
},
|
|
downloadHistory: [],
|
|
});
|
|
configServiceMock.get.mockImplementation(async (key: string) => {
|
|
if (key === 'media_dir') {
|
|
return '/media';
|
|
}
|
|
return null;
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
|
fsMock.access.mockRejectedValue(new Error('missing'));
|
|
prismaMock.request.update.mockResolvedValue({});
|
|
prismaMock.audiobook.update.mockResolvedValue({});
|
|
|
|
const { deleteABSItem } = await import('@/lib/services/audiobookshelf/api');
|
|
vi.mocked(deleteABSItem).mockRejectedValue(new Error('ABS API error'));
|
|
|
|
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
|
const result = await deleteRequest('req-7', 'admin-7');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(deleteABSItem).toHaveBeenCalledWith('abs-item-456');
|
|
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ deletedBy: 'admin-7' }),
|
|
})
|
|
);
|
|
});
|
|
});
|