/** * 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); }); });