mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
edc56bc457
Introduce manual import workflow and download permission support. Adds a Prisma migration and schema field (users.download_access) to track per-user download access, and updates admin UI to toggle global and per-user download access. Implements new APIs: filesystem browse, manual-import endpoint, download-access settings, audiobook download-status, and on-demand download-token generation. Adds frontend components for manual import and related tests, plus documentation for the manual-import feature and the documentation-agent prompt. Key files: prisma/migrations/20260212000000_add_download_access_permission/migration.sql, prisma/schema.prisma, src/app/api/admin/filesystem/browse/route.ts, src/app/api/admin/manual-import/route.ts, src/app/api/admin/settings/download-access/route.ts, src/app/api/requests/[id]/download-token/route.ts, src/app/api/audiobooks/[asin]/download-status/route.ts, and updated admin users pages/components and permissions util.
841 lines
28 KiB
TypeScript
841 lines
28 KiB
TypeScript
/**
|
|
* 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(),
|
|
addNotificationJob: vi.fn(() => Promise.resolve()),
|
|
addDownloadJob: 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' },
|
|
});
|
|
|
|
// Mock first request.findFirst call (check for existing requests by ASIN)
|
|
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
|
// Mock findPlexMatch
|
|
findPlexMatchMock.mockResolvedValueOnce(null);
|
|
// Mock audiobook.findFirst
|
|
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
|
|
|
prismaMock.audiobook.create.mockResolvedValueOnce({
|
|
id: 'ab-7',
|
|
title: 'Test Book',
|
|
author: 'Test Author',
|
|
audibleAsin: 'ASIN-7',
|
|
} as any);
|
|
|
|
// Mock second request.findFirst call (check for user's existing request)
|
|
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
|
|
|
prismaMock.user.findUnique.mockResolvedValue({
|
|
id: 'user-1',
|
|
role: 'user',
|
|
autoApproveRequests: true,
|
|
plexUsername: 'testuser',
|
|
} 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,
|
|
interactiveSearchAccess: true,
|
|
downloadAccess: 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',
|
|
selectedTorrent: null,
|
|
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',
|
|
selectedTorrent: null,
|
|
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();
|
|
});
|
|
});
|
|
});
|