mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +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:
@@ -147,6 +147,66 @@ describe('Admin settings core routes', () => {
|
||||
expect(invalidateQbMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates paths settings with custom audiobook path template', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
audiobookPathTemplate: '{author}/{title} - {narrator}',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/paths/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.configuration.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { key: 'audiobook_path_template' },
|
||||
update: { value: '{author}/{title} - {narrator}' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects paths settings when directories are the same', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
downloadDir: '/same',
|
||||
mediaDir: '/same',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/paths/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain('must be different');
|
||||
});
|
||||
|
||||
it('rejects paths settings when directories are missing', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
downloadDir: '',
|
||||
mediaDir: '/media',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/paths/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain('required');
|
||||
});
|
||||
|
||||
it('updates Prowlarr settings', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) };
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('Admin users routes', () => {
|
||||
authProvider: 'local',
|
||||
plexUsername: 'user',
|
||||
deletedAt: null,
|
||||
role: 'user',
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({ id: 'u3', plexUsername: 'user', role: 'admin' });
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'admin' }) };
|
||||
@@ -66,6 +67,72 @@ describe('Admin users routes', () => {
|
||||
expect(payload.user.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('allows autoApproveRequests update for OIDC users without role change', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'oidc',
|
||||
plexUsername: 'oidc-user',
|
||||
deletedAt: null,
|
||||
role: 'user',
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({
|
||||
id: 'oidc-1',
|
||||
plexUsername: 'oidc-user',
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'user', autoApproveRequests: true }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'oidc-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.user.autoApproveRequests).toBe(true);
|
||||
});
|
||||
|
||||
it('prevents OIDC user role change', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'oidc',
|
||||
plexUsername: 'oidc-user',
|
||||
deletedAt: null,
|
||||
role: 'user',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'admin', autoApproveRequests: true }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'oidc-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toContain('OIDC');
|
||||
});
|
||||
|
||||
it('allows autoApproveRequests update for setup admin without role change', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: true,
|
||||
authProvider: 'local',
|
||||
plexUsername: 'setup-admin',
|
||||
deletedAt: null,
|
||||
role: 'admin',
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({
|
||||
id: 'setup-1',
|
||||
plexUsername: 'setup-admin',
|
||||
role: 'admin',
|
||||
autoApproveRequests: true,
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'admin', autoApproveRequests: true }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'setup-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.user.autoApproveRequests).toBe(true);
|
||||
});
|
||||
|
||||
it('soft deletes a local user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u4',
|
||||
|
||||
@@ -19,7 +19,7 @@ const configServiceMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
}));
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addSearchJob: vi.fn(),
|
||||
addSearchJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const bookdateHelpersMock = vi.hoisted(() => ({
|
||||
buildAIPrompt: vi.fn(),
|
||||
|
||||
@@ -185,6 +185,7 @@ describe('Request action routes', () => {
|
||||
it('downloads ebook and returns success', 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');
|
||||
@@ -199,7 +200,7 @@ describe('Request action routes', () => {
|
||||
downloadEbookMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
format: 'epub',
|
||||
filePath: '/media/audiobooks/Author/Title (2022) ASIN123/Title.epub',
|
||||
filePath: '/media/audiobooks/Author/Title ASIN123/Title.epub',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
@@ -211,7 +212,7 @@ describe('Request action routes', () => {
|
||||
'ASIN123',
|
||||
'Title',
|
||||
'Author',
|
||||
expect.stringContaining('Title (2022) ASIN123'),
|
||||
expect.stringContaining('Title ASIN123'),
|
||||
'epub',
|
||||
'https://ebooks.example',
|
||||
undefined,
|
||||
|
||||
@@ -0,0 +1,823 @@
|
||||
/**
|
||||
* Component: Request Approval API Route Tests
|
||||
* Documentation: documentation/admin-features/request-approval.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addSearchJob: vi.fn(),
|
||||
}));
|
||||
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: findPlexMatchMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => ({
|
||||
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Request Approval Workflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'user-1', sub: 'user-1', role: 'user' },
|
||||
nextUrl: new URL('http://localhost/api/requests'),
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
describe('1. Request Creation with Approval Logic', () => {
|
||||
beforeEach(() => {
|
||||
// Setup common mocks for request creation
|
||||
prismaMock.request.findFirst.mockResolvedValue(null);
|
||||
findPlexMatchMock.mockResolvedValue(null);
|
||||
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||
prismaMock.audiobook.create.mockResolvedValue({
|
||||
id: 'ab-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
audibleAsin: 'ASIN-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('Admin user creates request → should auto-approve (status: pending)', async () => {
|
||||
authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' };
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-1', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'admin-1',
|
||||
role: 'admin',
|
||||
autoApproveRequests: null,
|
||||
} as any);
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
status: 'pending',
|
||||
userId: 'admin-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-1' },
|
||||
user: { id: 'admin-1', plexUsername: 'admin' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'pending' }),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-1', expect.any(Object));
|
||||
});
|
||||
|
||||
it('User with autoApproveRequests=true → should auto-approve (status: pending)', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-2', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
} as any);
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'req-2',
|
||||
status: 'pending',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-2' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'pending' }),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-2', expect.any(Object));
|
||||
});
|
||||
|
||||
it('User with autoApproveRequests=false → should require approval (status: awaiting_approval)', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-3', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: false,
|
||||
} as any);
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'req-3',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-3' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'awaiting_approval' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('User with autoApproveRequests=null + global=true → should auto-approve (status: pending)', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-4', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: null,
|
||||
} as any);
|
||||
|
||||
prismaMock.configuration.findUnique.mockResolvedValue({
|
||||
key: 'auto_approve_requests',
|
||||
value: 'true',
|
||||
} as any);
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'req-4',
|
||||
status: 'pending',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-4' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'pending' }),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-4', expect.any(Object));
|
||||
});
|
||||
|
||||
it('User with autoApproveRequests=null + global=false → should require approval (status: awaiting_approval)', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-5', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: null,
|
||||
} as any);
|
||||
|
||||
prismaMock.configuration.findUnique.mockResolvedValue({
|
||||
key: 'auto_approve_requests',
|
||||
value: 'false',
|
||||
} as any);
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'req-5',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-5' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'awaiting_approval' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('Request requiring approval should NOT trigger search job', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-6', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: false,
|
||||
} as any);
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'req-6',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-6' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
await POST({} as any);
|
||||
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Auto-approved request SHOULD trigger search job', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-7', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||
id: 'ab-7',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
audibleAsin: 'ASIN-7',
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
} as any);
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'req-7',
|
||||
status: 'pending',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-7', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-7' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
await POST({} as any);
|
||||
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-7', {
|
||||
id: 'ab-7',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
asin: 'ASIN-7',
|
||||
});
|
||||
});
|
||||
|
||||
it('Request with skipAutoSearch=true should have status awaiting_search and not trigger job', async () => {
|
||||
authRequest.nextUrl = new URL('http://localhost/api/requests?skipAutoSearch=true');
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-8', title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
} as any);
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'req-8',
|
||||
status: 'awaiting_search',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-8' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
await POST({} as any);
|
||||
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'awaiting_search' }),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. Global Auto-Approve Settings API', () => {
|
||||
beforeEach(() => {
|
||||
authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' };
|
||||
});
|
||||
|
||||
it('GET /api/admin/settings/auto-approve returns current setting', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValue({
|
||||
key: 'auto_approve_requests',
|
||||
value: 'true',
|
||||
} as any);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/settings/auto-approve/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.autoApproveRequests).toBe(true);
|
||||
expect(prismaMock.configuration.findUnique).toHaveBeenCalledWith({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PATCH /api/admin/settings/auto-approve updates setting', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ autoApproveRequests: false }),
|
||||
};
|
||||
|
||||
prismaMock.configuration.upsert.mockResolvedValue({
|
||||
key: 'auto_approve_requests',
|
||||
value: 'false',
|
||||
} as any);
|
||||
|
||||
const { PATCH } = await import('@/app/api/admin/settings/auto-approve/route');
|
||||
const response = await PATCH(mockRequest as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.autoApproveRequests).toBe(false);
|
||||
expect(prismaMock.configuration.upsert).toHaveBeenCalledWith({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
create: {
|
||||
key: 'auto_approve_requests',
|
||||
value: 'false',
|
||||
},
|
||||
update: {
|
||||
value: 'false',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Non-admin user cannot access endpoint (403)', async () => {
|
||||
authRequest.user = { id: 'user-1', sub: 'user-1', role: 'user' };
|
||||
requireAdminMock.mockImplementation((_req: any, _handler: any) => {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
||||
);
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/admin/settings/auto-approve/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('Missing/invalid values handled properly', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ autoApproveRequests: 'invalid' }),
|
||||
};
|
||||
|
||||
const { PATCH } = await import('@/app/api/admin/settings/auto-approve/route');
|
||||
const response = await PATCH(mockRequest as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain('must be a boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. Per-User Auto-Approve Settings', () => {
|
||||
beforeEach(() => {
|
||||
authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' };
|
||||
});
|
||||
|
||||
it('PUT /api/admin/users/[id] can update autoApproveRequests', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
}),
|
||||
};
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'plex',
|
||||
plexUsername: 'testuser',
|
||||
deletedAt: null,
|
||||
} as any);
|
||||
|
||||
prismaMock.user.update.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexUsername: 'testuser',
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
} as any);
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(mockRequest as any, { params: Promise.resolve({ id: 'user-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.user.autoApproveRequests).toBe(true);
|
||||
expect(prismaMock.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: { role: 'user', autoApproveRequests: true },
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Cannot set admin user autoApproveRequests to false (validation error)', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
role: 'admin',
|
||||
autoApproveRequests: false,
|
||||
}),
|
||||
};
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-2',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'plex',
|
||||
plexUsername: 'adminuser',
|
||||
deletedAt: null,
|
||||
} as any);
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(mockRequest as any, { params: Promise.resolve({ id: 'user-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain('Admins must always auto-approve');
|
||||
});
|
||||
|
||||
it('Non-admin user cannot update user settings (403)', async () => {
|
||||
authRequest.user = { id: 'user-1', sub: 'user-1', role: 'user' };
|
||||
requireAdminMock.mockImplementation((_req: any, _handler: any) => {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
||||
);
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(mockRequest as any, { params: Promise.resolve({ id: 'user-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. Request Approval API', () => {
|
||||
beforeEach(() => {
|
||||
authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' };
|
||||
});
|
||||
|
||||
it('POST /api/admin/requests/[id]/approve with action=approve changes status to pending and triggers search job', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ action: 'approve' }),
|
||||
};
|
||||
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-1' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
prismaMock.request.update.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
status: 'pending',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-1' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toContain('approved');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-1' },
|
||||
data: { status: 'pending' },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-1', {
|
||||
id: 'ab-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
asin: 'ASIN-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/admin/requests/[id]/approve with action=deny changes status to denied and does NOT trigger search job', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ action: 'deny' }),
|
||||
};
|
||||
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-2',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-2', title: 'Test Book 2', author: 'Test Author 2', audibleAsin: 'ASIN-2' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
prismaMock.request.update.mockResolvedValue({
|
||||
id: 'req-2',
|
||||
status: 'denied',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-2', title: 'Test Book 2', author: 'Test Author 2', audibleAsin: 'ASIN-2' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toContain('denied');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-2' },
|
||||
data: { status: 'denied' },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Cannot approve request that is not in awaiting_approval status (400)', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ action: 'approve' }),
|
||||
};
|
||||
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-3',
|
||||
status: 'pending',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-3', title: 'Test Book 3', author: 'Test Author 3', audibleAsin: 'ASIN-3' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('InvalidStatus');
|
||||
expect(payload.message).toContain('not awaiting approval');
|
||||
expect(payload.currentStatus).toBe('pending');
|
||||
});
|
||||
|
||||
it('Cannot approve non-existent request (404)', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ action: 'approve' }),
|
||||
};
|
||||
|
||||
prismaMock.request.findUnique.mockResolvedValue(null);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'non-existent' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('NotFound');
|
||||
expect(payload.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('Non-admin user cannot approve requests (403)', async () => {
|
||||
authRequest.user = { id: 'user-1', sub: 'user-1', role: 'user' };
|
||||
requireAdminMock.mockImplementation((_req: any, _handler: any) => {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
||||
);
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ action: 'approve' }),
|
||||
};
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('Missing action parameter returns error (400)', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-4',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-4', title: 'Test Book 4', author: 'Test Author 4', audibleAsin: 'ASIN-4' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
expect(payload.message).toContain('approve');
|
||||
});
|
||||
|
||||
it('Invalid action parameter returns error (400)', async () => {
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ action: 'invalid' }),
|
||||
};
|
||||
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-5',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-5', title: 'Test Book 5', author: 'Test Author 5', audibleAsin: 'ASIN-5' },
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
expect(payload.message).toContain('approve');
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. Pending Approval Requests API', () => {
|
||||
beforeEach(() => {
|
||||
authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' };
|
||||
});
|
||||
|
||||
it('GET /api/admin/requests/pending-approval returns only awaiting_approval requests', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1', title: 'Test Book 1', author: 'Test Author 1' },
|
||||
user: { id: 'user-1', plexUsername: 'user1', avatarUrl: null },
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'req-2',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-2',
|
||||
audiobook: { id: 'ab-2', title: 'Test Book 2', author: 'Test Author 2' },
|
||||
user: { id: 'user-2', plexUsername: 'user2', avatarUrl: null },
|
||||
createdAt: new Date(),
|
||||
},
|
||||
] as any);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.requests).toHaveLength(2);
|
||||
expect(payload.count).toBe(2);
|
||||
expect(prismaMock.request.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'awaiting_approval',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('Returns requests with audiobook and user details', async () => {
|
||||
const mockDate = new Date('2024-01-01');
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'awaiting_approval',
|
||||
userId: 'user-1',
|
||||
audiobook: {
|
||||
id: 'ab-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
audibleAsin: 'ASIN-1',
|
||||
coverArtUrl: 'https://example.com/cover.jpg',
|
||||
},
|
||||
user: {
|
||||
id: 'user-1',
|
||||
plexUsername: 'testuser',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
createdAt: mockDate,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.requests[0]).toMatchObject({
|
||||
id: 'req-1',
|
||||
status: 'awaiting_approval',
|
||||
audiobook: {
|
||||
id: 'ab-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
},
|
||||
user: {
|
||||
id: 'user-1',
|
||||
plexUsername: 'testuser',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Non-admin user cannot access endpoint (403)', async () => {
|
||||
authRequest.user = { id: 'user-1', sub: 'user-1', role: 'user' };
|
||||
requireAdminMock.mockImplementation((_req: any, _handler: any) => {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
||||
);
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,16 @@ import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn() }));
|
||||
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addSearchJob: vi.fn(),
|
||||
}));
|
||||
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -25,10 +31,6 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: findPlexMatchMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
describe('Requests API routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -89,11 +91,36 @@ describe('Requests API routes', () => {
|
||||
audibleAsin: 'ASIN-3',
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: true, // Auto-approve enabled for this user
|
||||
plexId: 'user-1',
|
||||
plexUsername: 'testuser',
|
||||
plexEmail: null,
|
||||
isSetupAdmin: false,
|
||||
avatarUrl: null,
|
||||
authToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastLoginAt: null,
|
||||
plexHomeUserId: null,
|
||||
authProvider: 'plex',
|
||||
oidcSubject: null,
|
||||
oidcProvider: null,
|
||||
registrationStatus: 'approved',
|
||||
bookDateLibraryScope: 'full',
|
||||
bookDateCustomPrompt: null,
|
||||
bookDateOnboardingComplete: false,
|
||||
deletedAt: null,
|
||||
deletedBy: null,
|
||||
} as any);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
status: 'pending',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN-3' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
});
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
@@ -124,6 +151,30 @@ describe('Requests API routes', () => {
|
||||
audibleAsin: 'ASIN-4',
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: true, // Auto-approve enabled for this user
|
||||
plexId: 'user-1',
|
||||
plexUsername: 'testuser',
|
||||
plexEmail: null,
|
||||
isSetupAdmin: false,
|
||||
avatarUrl: null,
|
||||
authToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastLoginAt: null,
|
||||
plexHomeUserId: null,
|
||||
authProvider: 'plex',
|
||||
oidcSubject: null,
|
||||
oidcProvider: null,
|
||||
registrationStatus: 'approved',
|
||||
bookDateLibraryScope: 'full',
|
||||
bookDateCustomPrompt: null,
|
||||
bookDateOnboardingComplete: false,
|
||||
deletedAt: null,
|
||||
deletedBy: null,
|
||||
} as any);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author', audibleAsin: 'ASIN-4' },
|
||||
|
||||
@@ -178,7 +178,89 @@ describe('Setup test routes', () => {
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.downloadDirValid).toBe(true);
|
||||
expect(payload.downloadDir.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('validates path template when provided', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-paths/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
audiobookPathTemplate: '{author}/{title} ({year})',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.template).toBeDefined();
|
||||
expect(payload.template.isValid).toBe(true);
|
||||
expect(payload.template.previewPaths).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns error for invalid path template', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-paths/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
audiobookPathTemplate: '{author}/{invalid_var}',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.template).toBeDefined();
|
||||
expect(payload.template.isValid).toBe(false);
|
||||
expect(payload.template.error).toContain('Unknown variable');
|
||||
expect(payload.template.previewPaths).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error when paths validation fails', async () => {
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
fsMock.mkdir.mockRejectedValue(new Error('no permissions'));
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-paths/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
downloadDir: '/bad/downloads',
|
||||
mediaDir: '/bad/media',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(false);
|
||||
expect(payload.downloadDir.valid).toBe(false);
|
||||
expect(payload.mediaDir.valid).toBe(false);
|
||||
expect(payload.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('validates template with absolute path and returns error', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-paths/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
audiobookPathTemplate: '/absolute/{author}/{title}',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.template).toBeDefined();
|
||||
expect(payload.template.isValid).toBe(false);
|
||||
expect(payload.template.error).toContain('absolute');
|
||||
});
|
||||
|
||||
it('tests Audiobookshelf connection with saved token', async () => {
|
||||
|
||||
@@ -23,11 +23,11 @@ const createModelMock = (): PrismaModelMock => ({
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(() => Promise.resolve({})),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
update: vi.fn(() => Promise.resolve({})),
|
||||
updateMany: vi.fn(() => Promise.resolve({})),
|
||||
upsert: vi.fn(() => Promise.resolve({})),
|
||||
delete: vi.fn(() => Promise.resolve({})),
|
||||
deleteMany: vi.fn(() => Promise.resolve({})),
|
||||
count: vi.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Tests for Path Template Engine Utility
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
substituteTemplate,
|
||||
validateTemplate,
|
||||
generateMockPreviews,
|
||||
getValidVariables,
|
||||
type TemplateVariables
|
||||
} from '@/lib/utils/path-template.util';
|
||||
|
||||
describe('substituteTemplate', () => {
|
||||
it('should substitute all valid variables', () => {
|
||||
const template = '{author}/{title}/{narrator}/{asin}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Brandon Sanderson',
|
||||
title: 'Mistborn',
|
||||
narrator: 'Michael Kramer',
|
||||
asin: 'B002UZMLXM'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Brandon Sanderson/Mistborn/Michael Kramer/B002UZMLXM');
|
||||
});
|
||||
|
||||
it('should handle missing optional variables gracefully', () => {
|
||||
const template = '{author}/{title}/{narrator}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Andy Weir',
|
||||
title: 'Project Hail Mary'
|
||||
// narrator is missing
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Andy Weir/Project Hail Mary');
|
||||
});
|
||||
|
||||
it('should sanitize invalid characters in values', () => {
|
||||
const template = '{author}/{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author: <Test>',
|
||||
title: 'Title|With*Invalid?Chars"'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).not.toContain('<');
|
||||
expect(result).not.toContain('>');
|
||||
expect(result).not.toContain(':');
|
||||
expect(result).not.toContain('|');
|
||||
expect(result).not.toContain('*');
|
||||
expect(result).not.toContain('?');
|
||||
expect(result).not.toContain('"');
|
||||
});
|
||||
|
||||
it('should remove multiple consecutive spaces', () => {
|
||||
const template = '{author}/{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author With Spaces',
|
||||
title: 'Title With Spaces'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author With Spaces/Title With Spaces');
|
||||
});
|
||||
|
||||
it('should handle empty string values', () => {
|
||||
const template = '{author}/{title}/{narrator}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title',
|
||||
narrator: ''
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/Title');
|
||||
});
|
||||
|
||||
it('should remove leading and trailing slashes', () => {
|
||||
const template = '/{author}/{title}/';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/Title');
|
||||
});
|
||||
|
||||
it('should collapse multiple consecutive slashes', () => {
|
||||
const template = '{author}//{title}///{narrator}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title',
|
||||
narrator: 'Narrator'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/Title/Narrator');
|
||||
});
|
||||
|
||||
it('should handle mixed forward and backward slashes', () => {
|
||||
const template = '{author}\\{title}/{narrator}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title',
|
||||
narrator: 'Narrator'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/Title/Narrator');
|
||||
});
|
||||
|
||||
it('should trim dots from path components', () => {
|
||||
const template = '{author}/{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: '...Author...',
|
||||
title: '..Title..'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result.startsWith('.')).toBe(false);
|
||||
expect(result.endsWith('.')).toBe(false);
|
||||
});
|
||||
|
||||
it('should limit path component length', () => {
|
||||
const template = '{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'A'.repeat(300) // Very long title
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('should handle static text in template', () => {
|
||||
const template = 'Audiobooks/{author}/Books/{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Audiobooks/Author/Books/Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTemplate', () => {
|
||||
it('should accept valid templates', () => {
|
||||
const templates = [
|
||||
'{author}/{title}',
|
||||
'{author}/{title}/{narrator}',
|
||||
'Audiobooks/{author}/{title}',
|
||||
'{author} - {title}',
|
||||
'{author}/{title}/{asin}'
|
||||
];
|
||||
|
||||
templates.forEach(template => {
|
||||
const result = validateTemplate(template);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject empty templates', () => {
|
||||
const result = validateTemplate('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('empty');
|
||||
});
|
||||
|
||||
it('should reject whitespace-only templates', () => {
|
||||
const result = validateTemplate(' ');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('empty');
|
||||
});
|
||||
|
||||
it('should reject unknown variables', () => {
|
||||
const result = validateTemplate('{author}/{invalid}');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Unknown variable');
|
||||
expect(result.error).toContain('{invalid}');
|
||||
});
|
||||
|
||||
it('should reject absolute paths with forward slash', () => {
|
||||
const result = validateTemplate('/absolute/path/{author}');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('absolute');
|
||||
});
|
||||
|
||||
it('should reject absolute paths with drive letter', () => {
|
||||
const result = validateTemplate('C:\\Users\\{author}');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('absolute');
|
||||
});
|
||||
|
||||
it('should reject invalid characters outside variables', () => {
|
||||
const invalidChars = ['<', '>', ':', '"', '|', '?', '*'];
|
||||
|
||||
invalidChars.forEach(char => {
|
||||
const result = validateTemplate(`{author}${char}{title}`);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Invalid characters');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject backslashes in template', () => {
|
||||
const result = validateTemplate('{author}\\{title}');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('forward slashes');
|
||||
});
|
||||
|
||||
it('should accept templates without variables', () => {
|
||||
const result = validateTemplate('Audiobooks/Default');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should provide helpful error messages for multiple unknown variables', () => {
|
||||
const result = validateTemplate('{author}/{invalid1}/{invalid2}');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Unknown variable');
|
||||
});
|
||||
|
||||
it('should list valid variables in error message', () => {
|
||||
const result = validateTemplate('{invalid}');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('{author}');
|
||||
expect(result.error).toContain('{title}');
|
||||
expect(result.error).toContain('{narrator}');
|
||||
expect(result.error).toContain('{asin}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMockPreviews', () => {
|
||||
it('should generate 3 preview examples', () => {
|
||||
const template = '{author}/{title}';
|
||||
const previews = generateMockPreviews(template);
|
||||
|
||||
expect(previews).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should apply template correctly to all examples', () => {
|
||||
const template = '{author}/{title}';
|
||||
const previews = generateMockPreviews(template);
|
||||
|
||||
previews.forEach(preview => {
|
||||
expect(preview).toContain('/');
|
||||
expect(preview.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include example without narrator', () => {
|
||||
const template = '{author}/{title}/{narrator}';
|
||||
const previews = generateMockPreviews(template);
|
||||
|
||||
// At least one preview should not have a third path component (no narrator)
|
||||
const withoutNarrator = previews.some(preview => {
|
||||
const parts = preview.split('/');
|
||||
return parts.length === 2; // Only author and title
|
||||
});
|
||||
|
||||
expect(withoutNarrator).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle templates with only static text', () => {
|
||||
const template = 'Static/Path/Example';
|
||||
const previews = generateMockPreviews(template);
|
||||
|
||||
previews.forEach(preview => {
|
||||
expect(preview).toBe('Static/Path/Example');
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize mock data values', () => {
|
||||
const template = '{author}/{title}';
|
||||
const previews = generateMockPreviews(template);
|
||||
|
||||
previews.forEach(preview => {
|
||||
expect(preview).not.toContain('<');
|
||||
expect(preview).not.toContain('>');
|
||||
expect(preview).not.toContain(':');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include ASIN in examples when requested', () => {
|
||||
const template = '{author}/{title}/{asin}';
|
||||
const previews = generateMockPreviews(template);
|
||||
|
||||
// All examples should have ASIN (mock data includes it)
|
||||
previews.forEach(preview => {
|
||||
const parts = preview.split('/');
|
||||
expect(parts.length).toBe(3);
|
||||
expect(parts[2]).toMatch(/^B[A-Z0-9]+$/); // ASIN format
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex templates with static text', () => {
|
||||
const template = 'Library/{author}/Books/{title} - {asin}';
|
||||
const previews = generateMockPreviews(template);
|
||||
|
||||
previews.forEach(preview => {
|
||||
expect(preview).toContain('Library/');
|
||||
expect(preview).toContain('/Books/');
|
||||
expect(preview).toContain(' - B');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidVariables', () => {
|
||||
it('should return all valid variable names', () => {
|
||||
const variables = getValidVariables();
|
||||
|
||||
expect(variables).toContain('author');
|
||||
expect(variables).toContain('title');
|
||||
expect(variables).toContain('narrator');
|
||||
expect(variables).toContain('asin');
|
||||
expect(variables).toContain('year');
|
||||
expect(variables).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should return a new array each time (not mutate original)', () => {
|
||||
const vars1 = getValidVariables();
|
||||
const vars2 = getValidVariables();
|
||||
|
||||
expect(vars1).toEqual(vars2);
|
||||
expect(vars1).not.toBe(vars2); // Different array instances
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ describe('processOrganizeFiles', () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'plex.trigger_scan_after_import') return 'true';
|
||||
if (key === 'plex_audiobook_library_id') return 'lib-1';
|
||||
if (key === 'audiobook_path_template') return '{author}/{title} {asin}';
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -95,6 +96,10 @@ describe('processOrganizeFiles', () => {
|
||||
maxImportRetries: 3,
|
||||
deletedAt: null,
|
||||
});
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'audiobook_path_template') return '{author}/{title} {asin}';
|
||||
return null;
|
||||
});
|
||||
|
||||
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
|
||||
const result = await processOrganizeFiles({
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,7 @@ describe('file organizer', () => {
|
||||
asin: 'ASIN123',
|
||||
coverArtUrl: '/api/cache/thumbnails/cover.jpg',
|
||||
},
|
||||
'{author}/{title} ({year}) {asin}',
|
||||
{ jobId: 'job-1', context: 'organize' }
|
||||
);
|
||||
|
||||
@@ -154,7 +155,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/empty', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('No audiobook files found in download');
|
||||
@@ -192,7 +193,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.filesMovedCount).toBe(2);
|
||||
@@ -235,7 +236,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
expect(result.success).toBe(true);
|
||||
@@ -272,7 +273,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available');
|
||||
@@ -319,7 +320,7 @@ describe('file organizer', () => {
|
||||
author: 'Author',
|
||||
asin: 'ASIN123',
|
||||
coverArtUrl: 'https://images.example/cover.jpg',
|
||||
});
|
||||
}, '{author}/{title} {asin}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
|
||||
@@ -369,7 +370,7 @@ describe('file organizer', () => {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
coverArtUrl: 'https://images.example/cover.jpg',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Failed to download cover art');
|
||||
@@ -405,7 +406,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.filesMovedCount).toBe(2);
|
||||
@@ -443,7 +444,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata');
|
||||
@@ -474,7 +475,7 @@ describe('file organizer', () => {
|
||||
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');
|
||||
@@ -544,7 +545,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Source file not found');
|
||||
@@ -576,7 +577,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.audioFiles).toEqual([targetPath]);
|
||||
@@ -609,7 +610,7 @@ describe('file organizer', () => {
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Metadata tagging failed');
|
||||
|
||||
Reference in New Issue
Block a user