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);
@@ -19,11 +19,18 @@ const configMock = vi.hoisted(() => ({
const thumbnailCacheServiceMock = vi.hoisted(() => ({
cacheLibraryThumbnail: vi.fn(),
}));
const jobQueueMock = vi.hoisted(() => ({
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/library', () => ({
getLibraryService: async () => libraryServiceMock,
}));
@@ -160,8 +167,11 @@ describe('processPlexRecentlyAddedCheck', () => {
narrator: 'Narrator A',
audibleAsin: 'ASIN-ABS',
},
user: {
plexUsername: 'testuser',
},
},
]);
] as any);
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue({
plexGuid: 'abs-item-1',
+11 -1
View File
@@ -19,11 +19,18 @@ const configMock = vi.hoisted(() => ({
const thumbnailCacheServiceMock = vi.hoisted(() => ({
cacheLibraryThumbnail: vi.fn(),
}));
const jobQueueMock = vi.hoisted(() => ({
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: vi.fn(),
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/audiobookshelf/api', () => ({
triggerABSItemMatch: vi.fn(),
}));
@@ -260,8 +267,11 @@ describe('processScanPlex', () => {
narrator: 'Narrator',
audibleAsin: 'ASIN123',
},
user: {
plexUsername: 'testuser',
},
},
]);
] as any);
prismaMock.audiobook.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({});
@@ -0,0 +1,118 @@
/**
* Component: Send Notification Processor Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const notificationServiceMock = vi.hoisted(() => ({
sendNotification: vi.fn(),
}));
vi.mock('@/lib/services/notification.service', () => ({
getNotificationService: () => notificationServiceMock,
}));
describe('processSendNotification', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls notification service with correct payload', async () => {
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const payload = {
event: 'request_approved' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
await processSendNotification(payload);
expect(notificationServiceMock.sendNotification).toHaveBeenCalledWith({
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: expect.any(Date),
});
});
it('includes error message if provided', async () => {
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const payload = {
event: 'request_error' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
message: 'Download failed',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
await processSendNotification(payload);
expect(notificationServiceMock.sendNotification).toHaveBeenCalledWith({
event: 'request_error',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
message: 'Download failed',
timestamp: expect.any(Date),
});
});
it('does not throw if notification service fails', async () => {
notificationServiceMock.sendNotification.mockRejectedValue(new Error('Service error'));
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const payload = {
event: 'request_approved' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
// Should not throw
await expect(processSendNotification(payload)).resolves.toBeUndefined();
});
it('processes all event types correctly', async () => {
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const events: Array<'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'> = [
'request_pending_approval',
'request_approved',
'request_available',
'request_error',
];
for (const event of events) {
const payload = {
event,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
await processSendNotification(payload);
}
expect(notificationServiceMock.sendNotification).toHaveBeenCalledTimes(4);
});
});
@@ -0,0 +1,91 @@
/**
* Component: Job Queue Notification Integration Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
describe('JobQueueService - Notification Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('addNotificationJob payload structure', () => {
it('creates correct payload for request_pending_approval', () => {
const event = 'request_pending_approval' as const;
const requestId = 'req-1';
const title = 'Test Book';
const author = 'Test Author';
const userName = 'Test User';
const payload = {
event,
requestId,
title,
author,
userName,
timestamp: new Date(),
};
expect(payload.event).toBe('request_pending_approval');
expect(payload.requestId).toBe(requestId);
expect(payload.title).toBe(title);
expect(payload.author).toBe(author);
expect(payload.userName).toBe(userName);
expect(payload.timestamp).toBeInstanceOf(Date);
});
it('includes error message for request_error events', () => {
const payload = {
event: 'request_error' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
message: 'Download failed',
timestamp: new Date(),
};
expect(payload.message).toBe('Download failed');
});
it('handles all event types', () => {
const events: Array<'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'> = [
'request_pending_approval',
'request_approved',
'request_available',
'request_error',
];
events.forEach((event) => {
const payload = {
event,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
};
expect(payload.event).toBe(event);
});
});
});
describe('notification job configuration', () => {
it('should use priority 5 for notification jobs', () => {
const priority = 5;
expect(priority).toBe(5);
});
it('should have concurrency 5 for send_notification processor', () => {
const concurrency = 5;
expect(concurrency).toBe(5);
});
it('should use job type send_notification', () => {
const jobType = 'send_notification';
expect(jobType).toBe('send_notification');
});
});
});
+414
View File
@@ -0,0 +1,414 @@
/**
* Component: Notification Service Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
prismaMock.notificationBackend = {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as any;
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((value: string) => `enc:${value}`),
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
}));
const fetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
global.fetch = fetchMock as any;
describe('NotificationService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('sendNotification', () => {
it('sends notifications to all enabled backends subscribed to the event', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([
{
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook1' },
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_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendNotification({
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
});
expect(prismaMock.notificationBackend.findMany).toHaveBeenCalledWith({
where: {
enabled: true,
events: { array_contains: 'request_approved' },
},
});
// Should send to both backends
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('does not send if no backends are subscribed to the event', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([]);
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendNotification({
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('continues sending to other backends if one fails', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([
{
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook1' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
type: 'pushover',
name: 'Pushover - Users',
config: { userKey: 'user123', appToken: 'app456' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
// First backend fails, second succeeds
fetchMock
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendNotification({
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
});
// Should still attempt both
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('sendDiscord', () => {
it('sends Discord webhook with rich embed', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendDiscord(
{
webhookUrl: 'enc:https://discord.com/webhook',
username: 'ReadMeABook',
},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
}
);
// Should call the webhook (URL decryption happens internally)
expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
expect(fetchCall[1].method).toBe('POST');
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
expect(body.username).toBe('ReadMeABook');
expect(body.embeds).toHaveLength(1);
expect(body.embeds[0].title).toBe('✅ Request Approved');
expect(body.embeds[0].color).toBe(2278750); // Green for approved (0x22C55E)
});
it('uses default username if not provided', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendDiscord(
{
webhookUrl: 'enc:https://discord.com/webhook',
},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.username).toBe('ReadMeABook');
});
it('throws error if Discord API returns non-OK response', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => 'Bad Request',
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await expect(
service.sendDiscord(
{ webhookUrl: 'enc:https://discord.com/webhook' },
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
)
).rejects.toThrow('Discord webhook failed: 400 Bad Request');
});
});
describe('sendPushover', () => {
it('sends Pushover notification with correct payload', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ status: 1 }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendPushover(
{
userKey: 'enc:user123',
appToken: 'enc:app456',
priority: 1,
},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
);
// Should call the Pushover API (credential decryption happens internally)
expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0];
expect(fetchCall[0]).toBe('https://api.pushover.net/1/messages.json');
expect(fetchCall[1].method).toBe('POST');
expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = fetchCall[1].body;
// Body should be URL-encoded string
expect(typeof body).toBe('string');
expect(body).toContain('priority=1');
});
it('uses default priority if not provided', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ status: 1 }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendPushover(
{
userKey: 'enc:user123',
appToken: 'enc:app456',
},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
);
const body = fetchMock.mock.calls[0][1].body;
expect(body.toString()).toContain('priority=0');
});
it('throws error if Pushover API returns non-OK response', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 400,
text: async () => 'invalid user key',
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await expect(
service.sendPushover(
{ userKey: 'enc:user123', appToken: 'enc:app456' },
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
)
).rejects.toThrow();
});
});
// Note: formatDiscordEmbed is a private method, tested indirectly through sendDiscord
describe('encryptConfig', () => {
it('encrypts sensitive Discord config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const encrypted = service.encryptConfig('discord', {
webhookUrl: 'https://discord.com/webhook',
username: 'ReadMeABook',
});
expect(encryptionMock.encrypt).toHaveBeenCalledWith('https://discord.com/webhook');
expect(encrypted.webhookUrl).toBe('enc:https://discord.com/webhook');
expect(encrypted.username).toBe('ReadMeABook'); // Not encrypted
});
it('encrypts sensitive Pushover config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const encrypted = service.encryptConfig('pushover', {
userKey: 'user123',
appToken: 'app456',
priority: 1,
});
expect(encryptionMock.encrypt).toHaveBeenCalledWith('user123');
expect(encryptionMock.encrypt).toHaveBeenCalledWith('app456');
expect(encrypted.userKey).toBe('enc:user123');
expect(encrypted.appToken).toBe('enc:app456');
expect(encrypted.priority).toBe(1); // Not encrypted
});
});
// Note: decryptConfig is a private method, tested indirectly through sendDiscord/sendPushover
describe('maskConfig', () => {
it('masks sensitive Discord config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const masked = service.maskConfig('discord', {
webhookUrl: 'https://discord.com/webhook/very/long/url',
username: 'ReadMeABook',
});
expect(masked.webhookUrl).toBe('••••••••');
expect(masked.username).toBe('ReadMeABook'); // Not masked
});
it('masks sensitive Pushover config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const masked = service.maskConfig('pushover', {
userKey: 'user123',
appToken: 'app456',
priority: 1,
});
expect(masked.userKey).toBe('••••••••');
expect(masked.appToken).toBe('••••••••');
expect(masked.priority).toBe(1); // Not masked
});
});
});
+72
View File
@@ -45,6 +45,78 @@ describe('PathMapper', () => {
PathMapper.validate({ enabled: true, remotePath: '/remote', localPath: '' })
).toThrow('Local path cannot be empty');
});
describe('reverseTransform', () => {
it('returns original path when mapping is disabled', () => {
const result = PathMapper.reverseTransform('/downloads/Book', {
enabled: false,
remotePath: 'F:\\Docker\\downloads\\completed\\books',
localPath: '/downloads',
});
expect(result).toBe('/downloads/Book');
});
it('transforms local path to remote path with Unix-style separators', () => {
const result = PathMapper.reverseTransform('/downloads/Audiobook.Name', {
enabled: true,
remotePath: '/remote/mnt/d/done',
localPath: '/downloads',
});
expect(result).toBe('/remote/mnt/d/done/Audiobook.Name');
});
it('transforms local path to remote path with Windows-style separators', () => {
const result = PathMapper.reverseTransform('/downloads/Audiobook.Name', {
enabled: true,
remotePath: 'F:\\Docker\\downloads\\completed\\books',
localPath: '/downloads',
});
expect(result).toBe('F:\\Docker\\downloads\\completed\\books\\Audiobook.Name');
});
it('returns original path when local prefix does not match', () => {
const result = PathMapper.reverseTransform('/other/path/book', {
enabled: true,
remotePath: 'F:\\Docker\\downloads\\completed\\books',
localPath: '/downloads',
});
expect(result).toBe('/other/path/book');
});
it('handles exact path match (no subdirectory)', () => {
const result = PathMapper.reverseTransform('/downloads', {
enabled: true,
remotePath: 'F:\\Docker\\downloads\\completed\\books',
localPath: '/downloads',
});
expect(result).toBe('F:\\Docker\\downloads\\completed\\books');
});
it('handles nested subdirectories', () => {
const result = PathMapper.reverseTransform('/downloads/Author/Book Name/file.m4b', {
enabled: true,
remotePath: 'F:\\seedbox\\audiobooks',
localPath: '/downloads',
});
expect(result).toBe('F:\\seedbox\\audiobooks\\Author\\Book Name\\file.m4b');
});
it('handles trailing slashes in config', () => {
const result = PathMapper.reverseTransform('/downloads/Book', {
enabled: true,
remotePath: '/remote/path/',
localPath: '/downloads/',
});
expect(result).toBe('/remote/path/Book');
});
});
});