Files
ReadMeABook/tests/api/notification-triggers.integration.test.ts
T
kikootwo dc7e557694 Add notification system with admin UI and backend
Introduces a full notification system with support for Discord and Pushover backends, event triggers, and message formatting. Adds backend services, processors, and API endpoints for managing notifications, as well as a new Notifications tab in the admin settings UI. Updates documentation, database schema, and tests to cover notification features and approval workflow improvements. Also changes project license from MIT to AGPL v3.
2026-01-28 11:42:00 -05:00

374 lines
11 KiB
TypeScript

/**
* Component: Notification Trigger Integration Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const jobQueueMock = vi.hoisted(() => ({
addNotificationJob: vi.fn(() => Promise.resolve('job-1')),
addSearchJob: vi.fn(() => Promise.resolve('job-2')),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
const audibleServiceMock = vi.hoisted(() => ({
getAudiobookDetails: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
describe('Notification Triggers - Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn(), nextUrl: { searchParams: { get: vi.fn() } } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
findPlexMatchMock.mockResolvedValue(null);
audibleServiceMock.getAudiobookDetails.mockResolvedValue({
releaseDate: '2024-01-01',
});
});
describe('POST /api/requests - Request Pending Approval', () => {
it('sends pending approval notification when user needs approval', async () => {
const requestBody = {
audiobook: {
asin: 'B001',
title: 'Test Book',
author: 'Test Author',
},
};
authRequest.json.mockResolvedValue(requestBody);
authRequest.nextUrl.searchParams.get.mockReturnValue(null);
prismaMock.request.findFirst.mockResolvedValue(null); // No existing active request
prismaMock.audiobook.findFirst.mockResolvedValue(null); // No existing audiobook
prismaMock.audiobook.create.mockResolvedValue({
id: 'audiobook-1',
audibleAsin: 'B001',
title: 'Test Book',
author: 'Test Author',
status: 'requested',
createdAt: new Date(),
updatedAt: new Date(),
});
// User needs approval (autoApproveRequests = false)
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'user',
autoApproveRequests: false,
});
prismaMock.request.create.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_approval',
progress: 0,
createdAt: new Date(),
updatedAt: new Date(),
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
});
const { POST } = await import('@/app/api/requests/route');
const response = await POST(authRequest as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_pending_approval',
'req-1',
'Test Book',
'Test Author',
'testuser'
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); // No search when awaiting approval
});
});
describe('POST /api/requests - Request Approved (Auto-Approval)', () => {
it('sends approved notification when user auto-approved with automatic search', async () => {
const requestBody = {
audiobook: {
asin: 'B001',
title: 'Test Book',
author: 'Test Author',
},
};
authRequest.json.mockResolvedValue(requestBody);
authRequest.nextUrl.searchParams.get.mockReturnValue(null); // skipAutoSearch = false
prismaMock.request.findFirst.mockResolvedValue(null);
prismaMock.audiobook.findFirst.mockResolvedValue(null);
prismaMock.audiobook.create.mockResolvedValue({
id: 'audiobook-1',
audibleAsin: 'B001',
title: 'Test Book',
author: 'Test Author',
status: 'requested',
createdAt: new Date(),
updatedAt: new Date(),
});
// User has auto-approve enabled
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'user',
autoApproveRequests: true,
});
prismaMock.request.create.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'pending',
progress: 0,
createdAt: new Date(),
updatedAt: new Date(),
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
});
const { POST } = await import('@/app/api/requests/route');
await POST(authRequest as any);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-1',
'Test Book',
'Test Author',
'testuser'
);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); // Search triggered
});
it('sends approved notification when user auto-approved with interactive search', async () => {
const requestBody = {
audiobook: {
asin: 'B001',
title: 'Test Book',
author: 'Test Author',
},
};
authRequest.json.mockResolvedValue(requestBody);
authRequest.nextUrl.searchParams.get.mockReturnValue('true'); // skipAutoSearch = true
prismaMock.request.findFirst.mockResolvedValue(null);
prismaMock.audiobook.findFirst.mockResolvedValue(null);
prismaMock.audiobook.create.mockResolvedValue({
id: 'audiobook-1',
audibleAsin: 'B001',
title: 'Test Book',
author: 'Test Author',
status: 'requested',
createdAt: new Date(),
updatedAt: new Date(),
});
// User has auto-approve enabled
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'user',
autoApproveRequests: true,
});
prismaMock.request.create.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_search',
progress: 0,
createdAt: new Date(),
updatedAt: new Date(),
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
});
const { POST } = await import('@/app/api/requests/route');
await POST(authRequest as any);
// Should still send approved notification even with interactive search
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-1',
'Test Book',
'Test Author',
'testuser'
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); // No automatic search
});
});
describe('POST /api/admin/requests/[id]/approve - Manual Approval', () => {
it('sends approved notification when admin manually approves request', async () => {
const adminRequest = {
user: { id: 'admin-1', role: 'admin' },
json: vi.fn().mockResolvedValue({}),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(adminRequest));
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_approval',
progress: 0,
audiobook: {
id: 'audiobook-1',
audibleAsin: 'B001',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
createdAt: new Date(),
updatedAt: new Date(),
});
prismaMock.request.update.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'pending',
progress: 0,
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
createdAt: new Date(),
updatedAt: new Date(),
});
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const approveRequest = {
json: vi.fn().mockResolvedValue({ action: 'approve' }),
};
await POST(approveRequest as any, { params: Promise.resolve({ id: 'req-1' }) });
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-1',
'Test Book',
'Test Author',
'testuser'
);
});
});
describe('Interactive Search - Approval Bypass Prevention', () => {
it('blocks interactive search when request awaiting approval', async () => {
authRequest.json.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_approval', // Awaiting approval
progress: 0,
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
createdAt: new Date(),
updatedAt: new Date(),
});
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
const response = await POST(authRequest as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('AwaitingApproval');
});
it('blocks torrent selection when request awaiting approval', async () => {
authRequest.json.mockResolvedValue({
torrent: {
title: 'Test Torrent',
downloadUrl: 'magnet:?xt=...',
},
});
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_approval', // Awaiting approval
progress: 0,
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
createdAt: new Date(),
updatedAt: new Date(),
});
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
const response = await POST(authRequest as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('AwaitingApproval');
});
});
});