learning_ai_notes/backend/src/modules/note-prompts/scheduler.test.ts

413 lines
14 KiB
TypeScript

/**
* Integration tests for prompt scheduler + webhook CRUD routes.
* Uses buildTestApp() + Fastify .inject() for real request/response flows.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
vi.mock('../../lib/auth.js', () => ({
extractAuth: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
requireWriter: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
}));
vi.mock('../../lib/product-config.js', () => ({
PRODUCT_ID: 'notelett',
DISPLAY_NAME: 'NoteLett',
productConfig: { productId: 'notelett', displayName: 'NoteLett' },
}));
vi.mock('../../lib/request-context.js', () => ({
getUserId: vi.fn(() => 'user_1'),
getRequestProductId: vi.fn(() => 'notelett'),
}));
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) }));
vi.mock('../../lib/field-encrypt.js', () => ({
initEncryption: vi.fn(),
getEncryptor: vi.fn(() => ({
encrypt: vi.fn(async (v: unknown) => v),
decrypt: vi.fn(async (v: unknown) => v),
})),
}));
vi.mock('../../lib/llm.js', () => ({
llm: vi.fn(() => ({
isConfigured: () => true,
chatCompletion: vi.fn(async () => ({
content: 'Mock LLM response',
model: 'mock-model',
usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 },
finishReason: 'stop',
})),
})),
initLLM: vi.fn(),
}));
vi.mock('../../lib/embeddings.js', () => ({
embedText: vi.fn(async () => null),
cosineSimilarity: vi.fn(() => 0),
stripHtmlForEmbedding: vi.fn((html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()),
}));
vi.mock('@bytelyst/llm', () => ({
getLLM: vi.fn(),
setLLM: vi.fn(),
createLLMProvider: vi.fn(),
buildVisionMessage: vi.fn((text: string, url: string) => ({
role: 'user',
content: [{ type: 'text', text }, { type: 'image_url', image_url: { url, detail: 'auto' } }],
})),
hasVisionContent: vi.fn(() => false),
isVisionMessage: vi.fn(() => false),
getMessageText: vi.fn((msg: { content: string }) => typeof msg.content === 'string' ? msg.content : ''),
}));
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
import { promptSchedulerRoutes } from './scheduler.js';
import { notePromptRoutes } from './routes.js';
import { noteRoutes } from '../notes/routes.js';
import { upsertBuiltinTemplate } from './repository.js';
import { getBuiltinTemplates } from './seed.js';
let app: FastifyInstance;
beforeAll(async () => {
app = await buildTestApp(async (fastify) => {
await noteRoutes(fastify);
await notePromptRoutes(fastify);
await promptSchedulerRoutes(fastify);
});
});
afterAll(async () => {
await app.close();
});
async function seedBuiltins() {
for (const t of getBuiltinTemplates()) {
await upsertBuiltinTemplate(t);
}
}
// ── Schedule CRUD ──────────────────────────────────────────────────
describe('prompt-schedules CRUD', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('POST /prompt-schedules — creates a schedule', async () => {
await seedBuiltins();
const res = await app.inject({
method: 'POST',
url: '/api/prompt-schedules',
payload: {
workspaceId: 'ws1',
templateId: 'builtin-summarize',
name: 'Weekly Digest',
cron: '0 9 * * 1',
enabled: true,
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.name).toBe('Weekly Digest');
expect(body.cron).toBe('0 9 * * 1');
expect(body.productId).toBe('notelett');
expect(body.userId).toBe('user_1');
expect(body.enabled).toBe(true);
expect(body.lastRunAt).toBeNull();
expect(body.id).toBeTruthy();
});
it('GET /prompt-schedules — lists schedules', async () => {
await app.inject({
method: 'POST',
url: '/api/prompt-schedules',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'S1', cron: '0 9 * * *' },
});
await app.inject({
method: 'POST',
url: '/api/prompt-schedules',
payload: { workspaceId: 'ws1', templateId: 't2', name: 'S2', cron: '0 10 * * *' },
});
const res = await app.inject({ method: 'GET', url: '/api/prompt-schedules' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.items.length).toBe(2);
expect(body.total).toBe(2);
});
it('GET /prompt-schedules/:id — returns a single schedule', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/prompt-schedules',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Get Test', cron: '0 8 * * *' },
});
const id = createRes.json().id;
const res = await app.inject({ method: 'GET', url: `/api/prompt-schedules/${id}` });
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Get Test');
});
it('GET /prompt-schedules/:id — 404 for unknown id', async () => {
const res = await app.inject({ method: 'GET', url: '/api/prompt-schedules/nonexistent' });
expect(res.statusCode).toBe(404);
});
it('PATCH /prompt-schedules/:id — updates schedule', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/prompt-schedules',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Original', cron: '0 8 * * *' },
});
const id = createRes.json().id;
const res = await app.inject({
method: 'PATCH',
url: `/api/prompt-schedules/${id}`,
payload: { name: 'Updated', enabled: false },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated');
expect(res.json().enabled).toBe(false);
});
it('DELETE /prompt-schedules/:id — deletes schedule', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/prompt-schedules',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'To Delete', cron: '0 8 * * *' },
});
const id = createRes.json().id;
const delRes = await app.inject({ method: 'DELETE', url: `/api/prompt-schedules/${id}` });
expect(delRes.statusCode).toBe(204);
const getRes = await app.inject({ method: 'GET', url: `/api/prompt-schedules/${id}` });
expect(getRes.statusCode).toBe(404);
});
it('DELETE /prompt-schedules/:id — 404 for unknown id', async () => {
const res = await app.inject({ method: 'DELETE', url: '/api/prompt-schedules/nonexistent' });
expect(res.statusCode).toBe(404);
});
});
// ── Webhook CRUD ──────────────────────────────────────────────────
describe('prompt-webhooks CRUD', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('POST /prompt-webhooks — creates a webhook', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: {
workspaceId: 'ws1',
templateId: 't1',
name: 'On Note Create',
triggerEvent: 'note.created',
enabled: true,
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.name).toBe('On Note Create');
expect(body.triggerEvent).toBe('note.created');
expect(body.productId).toBe('notelett');
expect(body.lastTriggeredAt).toBeNull();
});
it('GET /prompt-webhooks — lists webhooks', async () => {
await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'W1', triggerEvent: 'note.created' },
});
await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: { workspaceId: 'ws1', templateId: 't2', name: 'W2', triggerEvent: 'note.updated' },
});
const res = await app.inject({ method: 'GET', url: '/api/prompt-webhooks' });
expect(res.statusCode).toBe(200);
expect(res.json().items.length).toBe(2);
});
it('GET /prompt-webhooks/:id — returns single webhook', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Fetch Test', triggerEvent: 'note.tagged' },
});
const id = createRes.json().id;
const res = await app.inject({ method: 'GET', url: `/api/prompt-webhooks/${id}` });
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Fetch Test');
});
it('PATCH /prompt-webhooks/:id — updates webhook', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Original WH', triggerEvent: 'external' },
});
const id = createRes.json().id;
const res = await app.inject({
method: 'PATCH',
url: `/api/prompt-webhooks/${id}`,
payload: { name: 'Updated WH', enabled: false },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated WH');
expect(res.json().enabled).toBe(false);
});
it('DELETE /prompt-webhooks/:id — deletes webhook', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Del WH', triggerEvent: 'note.created' },
});
const id = createRes.json().id;
const delRes = await app.inject({ method: 'DELETE', url: `/api/prompt-webhooks/${id}` });
expect(delRes.statusCode).toBe(204);
const getRes = await app.inject({ method: 'GET', url: `/api/prompt-webhooks/${id}` });
expect(getRes.statusCode).toBe(404);
});
});
// ── Webhook Trigger ──────────────────────────────────────────────
describe('prompt-webhooks trigger', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('POST /prompt-webhooks/:id/trigger — executes prompt on note', async () => {
await seedBuiltins();
// Create a note
await app.inject({
method: 'POST',
url: '/api/notes',
payload: { id: 'wh-note-1', workspaceId: 'ws-wh', title: 'Webhook Target', body: 'Some content to process.' },
});
// Create webhook linked to builtin summarize
const whRes = await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: {
workspaceId: 'ws-wh',
templateId: 'builtin-summarize',
name: 'Trigger Test',
triggerEvent: 'external',
enabled: true,
},
});
const whId = whRes.json().id;
// Trigger it
const triggerRes = await app.inject({
method: 'POST',
url: `/api/prompt-webhooks/${whId}/trigger`,
payload: { noteId: 'wh-note-1', workspaceId: 'ws-wh' },
});
expect(triggerRes.statusCode).toBe(200);
const body = triggerRes.json();
expect(body.triggered).toBe(true);
expect(body.webhookId).toBe(whId);
expect(body.result.content).toBeTruthy();
});
it('POST /prompt-webhooks/:id/trigger — 404 for disabled webhook', async () => {
await seedBuiltins();
await app.inject({
method: 'POST',
url: '/api/notes',
payload: { id: 'wh-note-2', workspaceId: 'ws-wh2', title: 'T', body: 'B' },
});
const whRes = await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: {
workspaceId: 'ws-wh2',
templateId: 'builtin-summarize',
name: 'Disabled',
triggerEvent: 'external',
enabled: false,
},
});
const whId = whRes.json().id;
const triggerRes = await app.inject({
method: 'POST',
url: `/api/prompt-webhooks/${whId}/trigger`,
payload: { noteId: 'wh-note-2', workspaceId: 'ws-wh2' },
});
expect(triggerRes.statusCode).toBe(404);
});
it('POST /prompt-webhooks/:id/trigger — 404 for nonexistent note', async () => {
await seedBuiltins();
const whRes = await app.inject({
method: 'POST',
url: '/api/prompt-webhooks',
payload: {
workspaceId: 'ws-wh3',
templateId: 'builtin-summarize',
name: 'Bad Note',
triggerEvent: 'external',
enabled: true,
},
});
const whId = whRes.json().id;
const triggerRes = await app.inject({
method: 'POST',
url: `/api/prompt-webhooks/${whId}/trigger`,
payload: { noteId: 'nonexistent-note', workspaceId: 'ws-wh3' },
});
expect(triggerRes.statusCode).toBe(404);
});
});
// ── Scheduler diagnostics ────────────────────────────────────────
describe('scheduler diagnostics', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('GET /prompt-schedules/diagnostics — returns scheduler state', async () => {
await app.inject({
method: 'POST',
url: '/api/prompt-schedules',
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Active', cron: '0 9 * * *', enabled: true },
});
await app.inject({
method: 'POST',
url: '/api/prompt-schedules',
payload: { workspaceId: 'ws1', templateId: 't2', name: 'Disabled', cron: '0 10 * * *', enabled: false },
});
const res = await app.inject({ method: 'GET', url: '/api/prompt-schedules/diagnostics' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.totalSchedules).toBe(2);
expect(body.enabled).toBe(1);
expect(typeof body.dueNow).toBe('number');
expect(Array.isArray(body.nextRuns)).toBe(true);
});
});