mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
af0eaceb98
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.
362 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|