mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add request approval system and audiobook path template
Implements admin approval workflow for user requests with global and per-user auto-approve controls. Adds new request statuses ('awaiting_approval', 'denied'), related API endpoints, and UI for pending approvals. Introduces configurable audiobook organization path template with validation and preview in settings, updates database schema and migrations for new fields.
This commit is contained in:
@@ -144,7 +144,8 @@ describe('E-book sidecar', () => {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('finish'), 0);
|
||||
// Emit synchronously to avoid race condition with download timeout
|
||||
setImmediate(() => dest.emit('finish'));
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
@@ -192,7 +193,8 @@ describe('E-book sidecar', () => {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('finish'), 0);
|
||||
// Emit synchronously to avoid race condition with download timeout
|
||||
setImmediate(() => dest.emit('finish'));
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
@@ -335,7 +337,8 @@ describe('E-book sidecar', () => {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('finish'), 0);
|
||||
// Emit synchronously to avoid race condition with download timeout
|
||||
setImmediate(() => dest.emit('finish'));
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
@@ -619,7 +622,8 @@ describe('E-book sidecar', () => {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('finish'), 0);
|
||||
// Emit synchronously to avoid race condition with download timeout
|
||||
setImmediate(() => dest.emit('finish'));
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -42,6 +42,17 @@ 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();
|
||||
@@ -83,6 +94,9 @@ describe('deleteRequest', () => {
|
||||
if (key === 'media_dir') {
|
||||
return '/media';
|
||||
}
|
||||
if (key === 'audiobook_path_template') {
|
||||
return '{author}/{title} {asin}';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
@@ -90,7 +104,7 @@ describe('deleteRequest', () => {
|
||||
name: 'Book',
|
||||
seeding_time: 120,
|
||||
});
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValue({
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
||||
releaseDate: '2021-01-01T00:00:00.000Z',
|
||||
});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
@@ -109,7 +123,7 @@ describe('deleteRequest', () => {
|
||||
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
|
||||
expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'lib-1' } });
|
||||
|
||||
const expectedPath = path.join('/media', 'Author', 'Book (2021) ASIN1');
|
||||
const expectedPath = path.join('/media', 'Author', 'Book ASIN1');
|
||||
expect(fsMock.rm).toHaveBeenCalledWith(expectedPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -162,7 +176,7 @@ describe('deleteRequest', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps torrents seeding when requirement is not met and deletes fallback path', async () => {
|
||||
it('keeps torrents seeding when requirement is not met', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-3',
|
||||
audiobook: {
|
||||
@@ -188,6 +202,9 @@ describe('deleteRequest', () => {
|
||||
if (key === 'media_dir') {
|
||||
return '/media';
|
||||
}
|
||||
if (key === 'audiobook_path_template') {
|
||||
return '{author}/{title} {asin}';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
@@ -195,7 +212,7 @@ describe('deleteRequest', () => {
|
||||
name: 'Book Three',
|
||||
seeding_time: 60,
|
||||
});
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValue({
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
|
||||
releaseDate: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
@@ -214,8 +231,8 @@ describe('deleteRequest', () => {
|
||||
expect(result.torrentsKeptSeeding).toBe(1);
|
||||
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
|
||||
|
||||
const fallbackPath = path.join('/media', 'Author Name', 'Book Three');
|
||||
expect(fsMock.rm).toHaveBeenCalledWith(fallbackPath, { recursive: true, force: true });
|
||||
// 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 () => {
|
||||
@@ -307,4 +324,90 @@ describe('deleteRequest', () => {
|
||||
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' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user