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.
This commit is contained in:
kikootwo
2026-01-21 15:28:23 -05:00
parent ac2ad8aac2
commit dc7e557694
51 changed files with 5065 additions and 264 deletions
@@ -0,0 +1,130 @@
/**
* Component: Admin Notifications Test API Route Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const notificationServiceMock = vi.hoisted(() => ({
encryptConfig: vi.fn((type: string, config: any) => ({ ...config, encrypted: true })),
sendNotification: vi.fn(),
sendToBackend: vi.fn(),
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/notification.service', () => ({
getNotificationService: () => notificationServiceMock,
}));
describe('Admin notifications test route', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
});
describe('POST /api/admin/notifications/test', () => {
it('sends test notification successfully', async () => {
const testConfig = {
type: 'discord',
config: { webhookUrl: 'https://discord.com/webhook' },
};
authRequest.json.mockResolvedValue(testConfig);
notificationServiceMock.sendNotification.mockResolvedValue(undefined);
const { POST } = await import('@/app/api/admin/notifications/test/route');
const response = await POST({ json: authRequest.json } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.message).toContain('successfully');
expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('discord', testConfig.config);
expect(notificationServiceMock.sendToBackend).toHaveBeenCalled();
});
it('returns error if notification fails', async () => {
const testConfig = {
type: 'discord',
config: { webhookUrl: 'https://discord.com/webhook' },
};
authRequest.json.mockResolvedValue(testConfig);
notificationServiceMock.sendToBackend.mockRejectedValue(new Error('Webhook failed'));
const { POST } = await import('@/app/api/admin/notifications/test/route');
const response = await POST({ json: authRequest.json } as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('NotificationError');
expect(payload.message).toContain('Webhook failed');
});
it('validates required fields', async () => {
authRequest.json.mockResolvedValue({
type: 'discord',
// Missing config
});
const { POST } = await import('@/app/api/admin/notifications/test/route');
const response = await POST({ json: authRequest.json } as any);
expect(response.status).toBe(400);
const payload = await response.json();
expect(payload.error).toBe('ValidationError');
});
it('uses correct test payload format', async () => {
const testConfig = {
type: 'discord',
config: { webhookUrl: 'https://discord.com/webhook' },
};
authRequest.json.mockResolvedValue(testConfig);
notificationServiceMock.sendToBackend.mockResolvedValue(undefined);
const { POST } = await import('@/app/api/admin/notifications/test/route');
await POST({ json: authRequest.json } as any);
expect(notificationServiceMock.sendToBackend).toHaveBeenCalledWith(
'discord',
expect.objectContaining({ encrypted: true }),
expect.objectContaining({
event: 'request_available',
requestId: 'test-request-id',
title: expect.any(String),
author: expect.any(String),
userName: 'Test User',
timestamp: expect.any(Date),
})
);
});
it('tests Pushover notification correctly', async () => {
const testConfig = {
type: 'pushover',
config: { userKey: 'user123', appToken: 'app456', priority: 1 },
};
authRequest.json.mockResolvedValue(testConfig);
notificationServiceMock.sendNotification.mockResolvedValue(undefined);
const { POST } = await import('@/app/api/admin/notifications/test/route');
const response = await POST({ json: authRequest.json } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('pushover', testConfig.config);
});
});
});
@@ -0,0 +1,360 @@
/**
* Component: Admin Notifications API Route 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();
prismaMock.notificationBackend = {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const notificationServiceMock = vi.hoisted(() => ({
encryptConfig: vi.fn((type: string, config: any) => ({ ...config, encrypted: true })),
maskConfig: vi.fn((type: string, config: any) => ({ ...config, masked: true })),
sendToBackend: vi.fn(),
sendNotification: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/notification.service', () => ({
getNotificationService: () => notificationServiceMock,
}));
describe('Admin notifications routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
});
describe('GET /api/admin/notifications', () => {
it('returns all notification backends with masked config', async () => {
const backends = [
{
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook', username: 'Bot' },
events: ['request_approved', 'request_available'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
type: 'pushover',
name: 'Pushover - Users',
config: { userKey: 'user123', appToken: 'app456' },
events: ['request_available'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
prismaMock.notificationBackend.findMany.mockResolvedValue(backends);
const { GET } = await import('@/app/api/admin/notifications/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.backends).toHaveLength(2);
expect(notificationServiceMock.maskConfig).toHaveBeenCalledTimes(2);
});
it('returns empty array if no backends configured', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([]);
const { GET } = await import('@/app/api/admin/notifications/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.backends).toHaveLength(0);
});
});
describe('POST /api/admin/notifications', () => {
it('creates new notification backend with encrypted config', async () => {
const newBackend = {
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook' },
events: ['request_approved'],
enabled: true,
};
authRequest.json.mockResolvedValue(newBackend);
prismaMock.notificationBackend.create.mockResolvedValue({
id: '1',
...newBackend,
config: { webhookUrl: 'https://discord.com/webhook', encrypted: true },
createdAt: new Date(),
updatedAt: new Date(),
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('discord', newBackend.config);
expect(prismaMock.notificationBackend.create).toHaveBeenCalled();
});
it('validates required fields', async () => {
authRequest.json.mockResolvedValue({
type: 'discord',
// Missing name, config, events
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
expect(response.status).toBe(400);
const payload = await response.json();
expect(payload.error).toBe('ValidationError');
});
it('validates at least one event is selected', async () => {
authRequest.json.mockResolvedValue({
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook' },
events: [], // Empty events array
enabled: true,
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
expect(response.status).toBe(400);
const payload = await response.json();
// The error field is just "ValidationError" but details are in the error string
expect(payload.error).toBeDefined();
expect(typeof payload.error).toBe('string');
});
it('validates Discord config has webhookUrl', async () => {
authRequest.json.mockResolvedValue({
type: 'discord',
name: 'Discord - Admins',
config: { username: 'Bot' }, // Missing webhookUrl
events: ['request_approved'],
enabled: true,
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
// Should return 500 because validation happens after Prisma mock fails
expect(response.status).toBeGreaterThanOrEqual(400);
const payload = await response.json();
expect(payload.error).toBeDefined();
});
it('validates Pushover config has userKey and appToken', async () => {
authRequest.json.mockResolvedValue({
type: 'pushover',
name: 'Pushover - Users',
config: { userKey: 'user123' }, // Missing appToken
events: ['request_approved'],
enabled: true,
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
// Should return error (400 or 500)
expect(response.status).toBeGreaterThanOrEqual(400);
const payload = await response.json();
expect(payload.error).toBeDefined();
});
});
describe('GET /api/admin/notifications/[id]', () => {
it('returns notification backend with masked config', async () => {
const backend = {
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
prismaMock.notificationBackend.findUnique.mockResolvedValue(backend);
const { GET } = await import('@/app/api/admin/notifications/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: '1' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.backend.id).toBe('1');
expect(notificationServiceMock.maskConfig).toHaveBeenCalled();
});
it('returns 404 if backend not found', async () => {
prismaMock.notificationBackend.findUnique.mockResolvedValue(null);
const { GET } = await import('@/app/api/admin/notifications/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'nonexistent' }) });
expect(response.status).toBe(404);
const payload = await response.json();
expect(payload.error).toBe('NotFound');
});
});
describe('PUT /api/admin/notifications/[id]', () => {
it('updates notification backend', async () => {
const existingBackend = {
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'enc:https://discord.com/old' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const updates = {
name: 'Discord - Updated',
events: ['request_approved', 'request_available'],
};
prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend);
authRequest.json.mockResolvedValue(updates);
prismaMock.notificationBackend.update.mockResolvedValue({
...existingBackend,
...updates,
});
const { PUT } = await import('@/app/api/admin/notifications/[id]/route');
const response = await PUT(
{ json: authRequest.json } as any,
{ params: Promise.resolve({ id: '1' }) }
);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.notificationBackend.update).toHaveBeenCalled();
});
it('preserves masked config values on update', async () => {
const existingBackend = {
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'enc:https://discord.com/webhook' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const updates = {
config: { webhookUrl: '••••••••', username: 'NewBot' }, // Masked webhook
};
prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend);
authRequest.json.mockResolvedValue(updates);
prismaMock.notificationBackend.update.mockResolvedValue(existingBackend);
const { PUT } = await import('@/app/api/admin/notifications/[id]/route');
const response = await PUT(
{ json: authRequest.json } as any,
{ params: Promise.resolve({ id: '1' }) }
);
expect(response.status).toBe(200);
// Should preserve existing encrypted webhook and add new username
expect(prismaMock.notificationBackend.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
config: expect.objectContaining({
webhookUrl: 'enc:https://discord.com/webhook', // Original encrypted value
username: 'NewBot',
}),
}),
})
);
});
it('returns 404 if backend not found', async () => {
prismaMock.notificationBackend.findUnique.mockResolvedValue(null);
authRequest.json.mockResolvedValue({ name: 'Updated' });
const { PUT } = await import('@/app/api/admin/notifications/[id]/route');
const response = await PUT(
{ json: authRequest.json } as any,
{ params: Promise.resolve({ id: 'nonexistent' }) }
);
expect(response.status).toBe(404);
});
});
describe('DELETE /api/admin/notifications/[id]', () => {
it('deletes notification backend', async () => {
const backend = {
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: {},
events: [],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
// Mock findUnique to return the backend (so it passes the existence check)
prismaMock.notificationBackend.findUnique.mockResolvedValue(backend);
// Mock delete to simulate successful deletion
prismaMock.notificationBackend.delete.mockResolvedValue(backend);
const { DELETE } = await import('@/app/api/admin/notifications/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: '1' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.notificationBackend.delete).toHaveBeenCalledWith({
where: { id: '1' },
});
});
it('returns 404 if backend not found', async () => {
prismaMock.notificationBackend.delete.mockRejectedValue(new Error('Record not found'));
const { DELETE } = await import('@/app/api/admin/notifications/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'nonexistent' }) });
expect(response.status).toBe(404);
});
});
});
@@ -12,6 +12,7 @@ const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addDownloadJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
@@ -31,6 +32,12 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
}),
}));
describe('Request with torrent route', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -51,7 +58,7 @@ describe('Request with torrent route', () => {
status: 'downloaded',
userId: 'user-2',
user: { plexUsername: 'other' },
});
} as any);
const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route');
const response = await POST({} as any);
@@ -69,13 +76,19 @@ describe('Request with torrent route', () => {
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce(null);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' });
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'user',
} as any);
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-2',
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
user: { id: 'user-1', plexUsername: 'user' },
});
} as any);
const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route');
const response = await POST({} as any);
+172 -5
View File
@@ -20,6 +20,7 @@ const configServiceMock = vi.hoisted(() => ({
}));
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn().mockResolvedValue(undefined),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const bookdateHelpersMock = vi.hoisted(() => ({
buildAIPrompt: vi.fn(),
@@ -29,11 +30,18 @@ const bookdateHelpersMock = vi.hoisted(() => ({
isAlreadyRequested: vi.fn(),
isAlreadySwiped: vi.fn(),
}));
const audibleServiceMock = vi.hoisted(() => ({
getAudiobookDetails: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
@@ -428,7 +436,7 @@ describe('BookDate routes', () => {
expect(payload.recommendations).toHaveLength(1);
});
it('records swipe and creates request on right swipe', async () => {
it('records swipe and creates request on right swipe (admin auto-approves)', async () => {
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-1',
@@ -436,21 +444,180 @@ describe('BookDate routes', () => {
title: 'Title',
author: 'Author',
audnexusAsin: 'ASIN',
});
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({});
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' });
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1' });
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'testuser',
} as any);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1', audiobook: { title: 'Title' }, user: { id: 'user-1', plexUsername: 'testuser' } } as any);
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
}),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-1',
'Title',
'Author',
'testuser'
);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
});
it('creates request with awaiting_approval status when approval required (user with autoApproveRequests=false)', async () => {
authRequest = { user: { id: 'user-2', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
authRequest.json.mockResolvedValue({ recommendationId: 'rec-2', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-2',
userId: 'user-2',
title: 'Title 2',
author: 'Author 2',
audnexusAsin: 'ASIN2',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-2', title: 'Title 2', author: 'Author 2', audibleAsin: 'ASIN2' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-2',
role: 'user',
autoApproveRequests: false,
plexUsername: 'testuser2',
} as any);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-2', audiobook: { title: 'Title 2' }, user: { id: 'user-2', plexUsername: 'testuser2' } } as any);
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'awaiting_approval',
}),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_pending_approval',
'req-2',
'Title 2',
'Author 2',
'testuser2'
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
});
it('creates request with pending status when user has autoApproveRequests=true', async () => {
authRequest = { user: { id: 'user-3', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
authRequest.json.mockResolvedValue({ recommendationId: 'rec-3', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-3',
userId: 'user-3',
title: 'Title 3',
author: 'Author 3',
audnexusAsin: 'ASIN3',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-3', title: 'Title 3', author: 'Author 3', audibleAsin: 'ASIN3' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-3',
role: 'user',
autoApproveRequests: true,
plexUsername: 'testuser3',
} as any);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-3', audiobook: { title: 'Title 3' }, user: { id: 'user-3', plexUsername: 'testuser3' } } as any);
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
}),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-3',
'Title 3',
'Author 3',
'testuser3'
);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
});
it('checks global setting when user autoApproveRequests is null', async () => {
authRequest = { user: { id: 'user-4', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
authRequest.json.mockResolvedValue({ recommendationId: 'rec-4', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-4',
userId: 'user-4',
title: 'Title 4',
author: 'Author 4',
audnexusAsin: 'ASIN4',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-4', title: 'Title 4', author: 'Author 4', audibleAsin: 'ASIN4' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-4',
role: 'user',
autoApproveRequests: null,
plexUsername: 'testuser4',
} as any);
prismaMock.configuration.findUnique.mockResolvedValueOnce({
key: 'auto_approve_requests',
value: 'false',
} as any);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-4', audiobook: { title: 'Title 4' }, user: { id: 'user-4', plexUsername: 'testuser4' } } as any);
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'awaiting_approval',
}),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_pending_approval',
'req-4',
'Title 4',
'Author 4',
'testuser4'
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
});
it('undoes last swipe', async () => {
prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({
id: 'swipe-1',
@@ -0,0 +1,373 @@
/**
* 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');
});
});
});
+10 -2
View File
@@ -19,6 +19,7 @@ const configState = vi.hoisted(() => ({
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
addDownloadJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const downloadEbookMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
@@ -114,9 +115,16 @@ describe('Request action routes', () => {
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-3',
userId: 'user-1',
status: 'awaiting_search',
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
});
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'downloading' });
} as any);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'testuser',
} as any);
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'downloading', audiobook: { title: 'Title' } } as any);
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-3' }) });
+16 -1
View File
@@ -13,6 +13,8 @@ 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());
@@ -269,17 +271,28 @@ describe('Request Approval Workflow', () => {
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({
@@ -520,6 +533,7 @@ describe('Request Approval Workflow', () => {
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' },
@@ -569,6 +583,7 @@ describe('Request Approval Workflow', () => {
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' },
+8 -1
View File
@@ -12,6 +12,7 @@ const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
@@ -31,6 +32,12 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
}),
}));
describe('Requests API routes', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -51,7 +58,7 @@ describe('Requests API routes', () => {
status: 'downloaded',
userId: 'user-2',
user: { plexUsername: 'someone' },
});
} as any);
const { POST } = await import('@/app/api/requests/route');
const response = await POST({} as any);