mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Component: Apprise Notification Provider 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,
|
||||
}));
|
||||
|
||||
describe('AppriseProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
describe('send — stateless mode (urls)', () => {
|
||||
it('sends notification to correct Apprise endpoint with JSON body', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
urls: 'slack://tokenA/tokenB/tokenC',
|
||||
authToken: 'mytoken123',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('http://apprise:8000/notify/');
|
||||
expect(fetchCall[1].method).toBe('POST');
|
||||
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
|
||||
expect(fetchCall[1].headers['Authorization']).toBe('Bearer mytoken123');
|
||||
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.urls).toBe('slack://tokenA/tokenB/tokenC');
|
||||
expect(body.title).toBe('Request Approved');
|
||||
expect(body.body).toContain('Test Book');
|
||||
expect(body.body).toContain('Test Author');
|
||||
expect(body.body).toContain('Test User');
|
||||
expect(body.type).toBe('success');
|
||||
});
|
||||
|
||||
it('strips trailing slashes from server URL', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000/', urls: 'slack://token' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('http://apprise:8000/notify/');
|
||||
});
|
||||
|
||||
it('does not include Authorization header when authToken is not provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[1].headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws error when neither urls nor configKey is provided', async () => {
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await expect(
|
||||
provider.send(
|
||||
{ serverUrl: 'http://apprise:8000' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Apprise requires either notification URLs or a config key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('send — stateful mode (configKey)', () => {
|
||||
it('sends notification to configKey endpoint', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
configKey: 'my-config',
|
||||
tag: 'audiobooks',
|
||||
},
|
||||
{
|
||||
event: 'request_available',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('http://apprise:8000/notify/my-config');
|
||||
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.tag).toBe('audiobooks');
|
||||
expect(body.title).toBe('Audiobook Available');
|
||||
expect(body.body).toContain('Test Book');
|
||||
expect(body.type).toBe('success');
|
||||
});
|
||||
|
||||
it('omits tag from body when not provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', configKey: 'my-config' },
|
||||
{
|
||||
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.tag).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers configKey over urls when both are provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
configKey: 'my-config',
|
||||
urls: 'slack://token',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('http://apprise:8000/notify/my-config');
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.urls).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification types by event', () => {
|
||||
it('maps event types to correct Apprise notification types', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
const events = [
|
||||
{ event: 'request_pending_approval', expectedType: 'info' },
|
||||
{ event: 'request_approved', expectedType: 'success' },
|
||||
{ event: 'request_available', expectedType: 'success' },
|
||||
{ event: 'request_error', expectedType: 'failure' },
|
||||
] as const;
|
||||
|
||||
for (const { event, expectedType } of events) {
|
||||
fetchMock.mockClear();
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||
{
|
||||
event,
|
||||
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.type).toBe(expectedType);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws descriptive error when API returns non-OK response', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await expect(
|
||||
provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Apprise API failed: 500 Internal Server Error');
|
||||
});
|
||||
|
||||
it('throws descriptive error on stateful mode failure', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 424,
|
||||
text: async () => 'No recipients',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await expect(
|
||||
provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', configKey: 'bad-key' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Apprise API failed: 424 No recipients');
|
||||
});
|
||||
|
||||
it('includes error message in notification body for error events', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||
{
|
||||
event: 'request_error',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
message: 'Download timed out',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.body).toContain('⚠️ Error: Download timed out');
|
||||
expect(body.type).toBe('failure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with NotificationService.sendToBackend', () => {
|
||||
it('decrypts sensitive fields and sends to Apprise', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
// Use iv:authTag:data format to pass isEncrypted() check
|
||||
// Note: the value must have exactly 3 colon-separated segments
|
||||
await service.sendToBackend(
|
||||
'apprise',
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
urls: 'iv:tag:encryptedUrlsData',
|
||||
authToken: 'iv:tag:mytoken123',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
// Verify decrypt was called for the sensitive fields
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:encryptedUrlsData');
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:mytoken123');
|
||||
|
||||
// Verify the decrypted values reach the fetch call
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[1].headers['Authorization']).toBe('Bearer iv:tag:mytoken123');
|
||||
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.urls).toBe('iv:tag:encryptedUrlsData');
|
||||
});
|
||||
|
||||
it('does not decrypt non-sensitive fields', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendToBackend(
|
||||
'apprise',
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
configKey: 'my-config',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
// decrypt should not be called since there are no sensitive fields with encrypted values
|
||||
expect(encryptionMock.decrypt).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptConfig and maskConfig', () => {
|
||||
it('encrypts urls and authToken', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const encrypted = service.encryptConfig('apprise', {
|
||||
serverUrl: 'http://apprise:8000',
|
||||
urls: 'slack://tokenA/tokenB',
|
||||
configKey: 'my-config',
|
||||
authToken: 'mytoken123',
|
||||
});
|
||||
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('slack://tokenA/tokenB');
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('mytoken123');
|
||||
expect(encrypted.urls).toBe('enc:slack://tokenA/tokenB');
|
||||
expect(encrypted.authToken).toBe('enc:mytoken123');
|
||||
expect(encrypted.serverUrl).toBe('http://apprise:8000'); // Not encrypted
|
||||
expect(encrypted.configKey).toBe('my-config'); // Not encrypted
|
||||
});
|
||||
|
||||
it('masks urls and authToken', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const masked = service.maskConfig('apprise', {
|
||||
serverUrl: 'http://apprise:8000',
|
||||
urls: 'slack://tokenA/tokenB',
|
||||
configKey: 'my-config',
|
||||
authToken: 'mytoken123',
|
||||
});
|
||||
|
||||
expect(masked.urls).toBe('••••••••');
|
||||
expect(masked.authToken).toBe('••••••••');
|
||||
expect(masked.serverUrl).toBe('http://apprise:8000'); // Not masked
|
||||
expect(masked.configKey).toBe('my-config'); // Not masked
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -191,6 +191,40 @@ describe('LocalAuthProvider', () => {
|
||||
expect(result.error).toContain('Password');
|
||||
});
|
||||
|
||||
it('allows short passwords when ALLOW_WEAK_PASSWORD is enabled', async () => {
|
||||
process.env.ALLOW_WEAK_PASSWORD = 'true';
|
||||
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
||||
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
||||
prismaMock.user.findFirst.mockResolvedValue(null);
|
||||
prismaMock.user.count.mockResolvedValue(0);
|
||||
prismaMock.user.create.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'admin',
|
||||
});
|
||||
bcryptHash.mockResolvedValue('hash');
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.register({ username: 'user', password: 'ab' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
delete process.env.ALLOW_WEAK_PASSWORD;
|
||||
});
|
||||
|
||||
it('still rejects empty passwords when ALLOW_WEAK_PASSWORD is enabled', async () => {
|
||||
process.env.ALLOW_WEAK_PASSWORD = 'true';
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.register({ username: 'user', password: '' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('required');
|
||||
delete process.env.ALLOW_WEAK_PASSWORD;
|
||||
});
|
||||
|
||||
it('rejects registration when username is taken', async () => {
|
||||
configMock.get.mockResolvedValueOnce('true');
|
||||
prismaMock.user.findFirst.mockResolvedValue({ id: 'user-10' });
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('NotificationService', () => {
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendNotification({
|
||||
@@ -92,7 +92,7 @@ describe('NotificationService', () => {
|
||||
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 { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendNotification({
|
||||
@@ -139,7 +139,7 @@ describe('NotificationService', () => {
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendNotification({
|
||||
@@ -156,19 +156,99 @@ describe('NotificationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendDiscord', () => {
|
||||
describe('sendToBackend', () => {
|
||||
it('routes to Discord provider and decrypts config', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendToBackend(
|
||||
'discord',
|
||||
{ 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'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:https://discord.com/webhook');
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
// Decrypted URL should be used
|
||||
expect(fetchCall[0]).toBe('https://discord.com/webhook');
|
||||
});
|
||||
|
||||
it('routes to Pushover provider and decrypts config', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 1 }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
// Use iv:authTag:data format to pass isEncrypted() check
|
||||
await service.sendToBackend(
|
||||
'pushover',
|
||||
{ userKey: 'iv:tag:user123', appToken: 'iv:tag:app456', priority: 1 },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:user123');
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:app456');
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws error for unsupported backend type', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await expect(
|
||||
service.sendToBackend(
|
||||
'email',
|
||||
{},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Unsupported backend type: email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscordProvider', () => {
|
||||
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();
|
||||
const { DiscordProvider } = await import('@/lib/services/notification');
|
||||
const provider = new DiscordProvider();
|
||||
|
||||
await service.sendDiscord(
|
||||
await provider.send(
|
||||
{
|
||||
webhookUrl: 'enc:https://discord.com/webhook',
|
||||
webhookUrl: 'https://discord.com/webhook',
|
||||
username: 'ReadMeABook',
|
||||
},
|
||||
{
|
||||
@@ -181,12 +261,12 @@ describe('NotificationService', () => {
|
||||
}
|
||||
);
|
||||
|
||||
// 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[0]).toBe('https://discord.com/webhook');
|
||||
expect(fetchCall[1].method).toBe('POST');
|
||||
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
|
||||
expect(body.username).toBe('ReadMeABook');
|
||||
@@ -201,12 +281,12 @@ describe('NotificationService', () => {
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { DiscordProvider } = await import('@/lib/services/notification');
|
||||
const provider = new DiscordProvider();
|
||||
|
||||
await service.sendDiscord(
|
||||
await provider.send(
|
||||
{
|
||||
webhookUrl: 'enc:https://discord.com/webhook',
|
||||
webhookUrl: 'https://discord.com/webhook',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
@@ -230,12 +310,12 @@ describe('NotificationService', () => {
|
||||
text: async () => 'Bad Request',
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { DiscordProvider } = await import('@/lib/services/notification');
|
||||
const provider = new DiscordProvider();
|
||||
|
||||
await expect(
|
||||
service.sendDiscord(
|
||||
{ webhookUrl: 'enc:https://discord.com/webhook' },
|
||||
provider.send(
|
||||
{ webhookUrl: 'https://discord.com/webhook' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
@@ -249,20 +329,20 @@ describe('NotificationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendPushover', () => {
|
||||
describe('PushoverProvider', () => {
|
||||
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();
|
||||
const { PushoverProvider } = await import('@/lib/services/notification');
|
||||
const provider = new PushoverProvider();
|
||||
|
||||
await service.sendPushover(
|
||||
await provider.send(
|
||||
{
|
||||
userKey: 'enc:user123',
|
||||
appToken: 'enc:app456',
|
||||
userKey: 'user123',
|
||||
appToken: 'app456',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
@@ -275,7 +355,6 @@ describe('NotificationService', () => {
|
||||
}
|
||||
);
|
||||
|
||||
// Should call the Pushover API (credential decryption happens internally)
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
@@ -296,13 +375,13 @@ describe('NotificationService', () => {
|
||||
json: async () => ({ status: 1 }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { PushoverProvider } = await import('@/lib/services/notification');
|
||||
const provider = new PushoverProvider();
|
||||
|
||||
await service.sendPushover(
|
||||
await provider.send(
|
||||
{
|
||||
userKey: 'enc:user123',
|
||||
appToken: 'enc:app456',
|
||||
userKey: 'user123',
|
||||
appToken: 'app456',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
@@ -325,12 +404,12 @@ describe('NotificationService', () => {
|
||||
text: async () => 'invalid user key',
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { PushoverProvider } = await import('@/lib/services/notification');
|
||||
const provider = new PushoverProvider();
|
||||
|
||||
await expect(
|
||||
service.sendPushover(
|
||||
{ userKey: 'enc:user123', appToken: 'enc:app456' },
|
||||
provider.send(
|
||||
{ userKey: 'user123', appToken: 'app456' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
@@ -344,11 +423,9 @@ describe('NotificationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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 { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const encrypted = service.encryptConfig('discord', {
|
||||
@@ -362,7 +439,7 @@ describe('NotificationService', () => {
|
||||
});
|
||||
|
||||
it('encrypts sensitive Pushover config values', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const encrypted = service.encryptConfig('pushover', {
|
||||
@@ -379,11 +456,72 @@ describe('NotificationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Note: decryptConfig is a private method, tested indirectly through sendDiscord/sendPushover
|
||||
describe('getRegisteredProviderTypes', () => {
|
||||
it('returns all registered provider type keys', async () => {
|
||||
const { getRegisteredProviderTypes } = await import('@/lib/services/notification');
|
||||
const types = getRegisteredProviderTypes();
|
||||
|
||||
expect(types).toContain('apprise');
|
||||
expect(types).toContain('discord');
|
||||
expect(types).toContain('ntfy');
|
||||
expect(types).toContain('pushover');
|
||||
expect(types).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllProviderMetadata', () => {
|
||||
it('returns metadata for all registered providers', async () => {
|
||||
const { getAllProviderMetadata } = await import('@/lib/services/notification');
|
||||
const metadata = getAllProviderMetadata();
|
||||
|
||||
expect(metadata).toHaveLength(4);
|
||||
|
||||
const apprise = metadata.find((m) => m.type === 'apprise');
|
||||
expect(apprise).toBeDefined();
|
||||
expect(apprise!.displayName).toBe('Apprise');
|
||||
expect(apprise!.iconLabel).toBe('A');
|
||||
expect(apprise!.iconColor).toBe('bg-purple-500');
|
||||
|
||||
const discord = metadata.find((m) => m.type === 'discord');
|
||||
expect(discord).toBeDefined();
|
||||
expect(discord!.displayName).toBe('Discord');
|
||||
expect(discord!.iconLabel).toBe('D');
|
||||
expect(discord!.iconColor).toBe('bg-indigo-500');
|
||||
expect(discord!.configFields.length).toBeGreaterThan(0);
|
||||
|
||||
const ntfy = metadata.find((m) => m.type === 'ntfy');
|
||||
expect(ntfy).toBeDefined();
|
||||
expect(ntfy!.displayName).toBe('ntfy');
|
||||
expect(ntfy!.iconLabel).toBe('N');
|
||||
|
||||
const pushover = metadata.find((m) => m.type === 'pushover');
|
||||
expect(pushover).toBeDefined();
|
||||
expect(pushover!.displayName).toBe('Pushover');
|
||||
expect(pushover!.iconLabel).toBe('P');
|
||||
});
|
||||
|
||||
it('includes config field definitions with correct properties', async () => {
|
||||
const { getAllProviderMetadata } = await import('@/lib/services/notification');
|
||||
const metadata = getAllProviderMetadata();
|
||||
|
||||
const discord = metadata.find((m) => m.type === 'discord')!;
|
||||
const webhookField = discord.configFields.find((f) => f.name === 'webhookUrl');
|
||||
expect(webhookField).toBeDefined();
|
||||
expect(webhookField!.required).toBe(true);
|
||||
expect(webhookField!.type).toBe('text');
|
||||
|
||||
const pushover = metadata.find((m) => m.type === 'pushover')!;
|
||||
const priorityField = pushover.configFields.find((f) => f.name === 'priority');
|
||||
expect(priorityField).toBeDefined();
|
||||
expect(priorityField!.type).toBe('select');
|
||||
expect(priorityField!.options).toBeDefined();
|
||||
expect(priorityField!.options!.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskConfig', () => {
|
||||
it('masks sensitive Discord config values', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const masked = service.maskConfig('discord', {
|
||||
@@ -396,7 +534,7 @@ describe('NotificationService', () => {
|
||||
});
|
||||
|
||||
it('masks sensitive Pushover config values', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const masked = service.maskConfig('pushover', {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Component: ntfy Notification Provider 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,
|
||||
}));
|
||||
|
||||
describe('NtfyProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('sends notification to correct ntfy endpoint with JSON body', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
accessToken: 'tk_mytoken123',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('https://ntfy.example.com/audiobooks');
|
||||
expect(fetchCall[1].method).toBe('POST');
|
||||
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
|
||||
expect(fetchCall[1].headers['Authorization']).toBe('Bearer tk_mytoken123');
|
||||
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.topic).toBe('audiobooks');
|
||||
expect(body.title).toBe('Request Approved');
|
||||
expect(body.message).toContain('Test Book');
|
||||
expect(body.message).toContain('Test Author');
|
||||
expect(body.message).toContain('Test User');
|
||||
expect(body.priority).toBe(3);
|
||||
expect(body.tags).toEqual(['white_check_mark']);
|
||||
});
|
||||
|
||||
it('uses default server URL (https://ntfy.sh) when not provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
topic: 'audiobooks',
|
||||
},
|
||||
{
|
||||
event: 'request_available',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('https://ntfy.sh/audiobooks');
|
||||
});
|
||||
|
||||
it('does not include Authorization header when accessToken is not provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
topic: 'audiobooks',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[1].headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses default priority based on event type when not configured', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
// request_error should default to priority 4 (high)
|
||||
await provider.send(
|
||||
{ topic: 'audiobooks' },
|
||||
{
|
||||
event: 'request_error',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
message: 'Download failed',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.priority).toBe(4);
|
||||
expect(body.tags).toEqual(['x']);
|
||||
expect(body.message).toContain('Download failed');
|
||||
});
|
||||
|
||||
it('uses configured priority over default', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{ topic: 'audiobooks', priority: 5 },
|
||||
{
|
||||
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.priority).toBe(5);
|
||||
});
|
||||
|
||||
it('strips trailing slashes from server URL', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'https://ntfy.example.com/', topic: 'audiobooks' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('https://ntfy.example.com/audiobooks');
|
||||
});
|
||||
|
||||
it('throws descriptive error when API returns non-OK response', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => 'unauthorized',
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await expect(
|
||||
provider.send(
|
||||
{ topic: 'audiobooks', accessToken: 'bad_token' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('ntfy API failed: 401 unauthorized');
|
||||
});
|
||||
|
||||
it('includes error message in notification body for error events', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{ topic: 'audiobooks' },
|
||||
{
|
||||
event: 'request_error',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
message: 'Download timed out',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.message).toContain('⚠️ Error: Download timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with NotificationService.sendToBackend', () => {
|
||||
it('decrypts accessToken and sends to ntfy', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
// Use iv:authTag:data format to pass isEncrypted() check
|
||||
await service.sendToBackend(
|
||||
'ntfy',
|
||||
{
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
accessToken: 'iv:tag:tk_mytoken123',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
// Verify decrypt was called for the sensitive field
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:tk_mytoken123');
|
||||
|
||||
// Verify the decrypted value reaches the fetch call
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[1].headers['Authorization']).toBe('Bearer iv:tag:tk_mytoken123');
|
||||
});
|
||||
|
||||
it('does not decrypt non-sensitive fields', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendToBackend(
|
||||
'ntfy',
|
||||
{
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
// decrypt should not be called since there's no accessToken
|
||||
expect(encryptionMock.decrypt).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptConfig and maskConfig', () => {
|
||||
it('encrypts accessToken', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const encrypted = service.encryptConfig('ntfy', {
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
accessToken: 'tk_mytoken123',
|
||||
});
|
||||
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('tk_mytoken123');
|
||||
expect(encrypted.accessToken).toBe('enc:tk_mytoken123');
|
||||
expect(encrypted.serverUrl).toBe('https://ntfy.example.com'); // Not encrypted
|
||||
expect(encrypted.topic).toBe('audiobooks'); // Not encrypted
|
||||
});
|
||||
|
||||
it('masks accessToken', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const masked = service.maskConfig('ntfy', {
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
accessToken: 'tk_mytoken123',
|
||||
});
|
||||
|
||||
expect(masked.accessToken).toBe('••••••••');
|
||||
expect(masked.serverUrl).toBe('https://ntfy.example.com'); // Not masked
|
||||
expect(masked.topic).toBe('audiobooks'); // Not masked
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user