mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40: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 () => {
|
||||
|
||||
Reference in New Issue
Block a user