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,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
});
});
});