Files
ReadMeABook/tests/api/admin-notifications.routes.test.ts
T
kikootwo af0eaceb98 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.
2026-02-10 15:06:20 -05:00

362 lines
12 KiB
TypeScript

/**
* 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', () => ({
getNotificationService: () => notificationServiceMock,
getRegisteredProviderTypes: () => ['discord', 'ntfy', 'pushover'],
}));
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);
});
});
});