/** * Tests for note-prompts module — CRUD + run + seed + reading-time. */ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { FastifyInstance } from 'fastify'; const { extractAuthMock } = vi.hoisted(() => ({ extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })), })); vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock })); 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('@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 { notePromptRoutes } from './routes.js'; import { noteRoutes } from '../notes/routes.js'; import { getBuiltinTemplates } from './seed.js'; import { upsertBuiltinTemplate } from './repository.js'; let app: FastifyInstance; beforeAll(async () => { app = await buildTestApp(async (fastify) => { await noteRoutes(fastify); await notePromptRoutes(fastify); }); }); afterAll(async () => { await app.close(); }); async function seedBuiltins() { for (const t of getBuiltinTemplates()) { await upsertBuiltinTemplate(t); } } describe('note-prompts CRUD', () => { beforeEach(() => { resetMemoryDatastore(); }); it('GET /note-prompts — lists builtins', async () => { await seedBuiltins(); const res = await app.inject({ method: 'GET', url: '/api/note-prompts' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.items.length).toBeGreaterThan(0); expect(body.items.some((t: { isBuiltin: boolean }) => t.isBuiltin)).toBe(true); }); it('GET /note-prompts?category=transform — filters by category', async () => { await seedBuiltins(); const res = await app.inject({ method: 'GET', url: '/api/note-prompts?category=transform' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.items.every((t: { category: string }) => t.category === 'transform')).toBe(true); }); it('POST /note-prompts — creates custom template', async () => { const res = await app.inject({ method: 'POST', url: '/api/note-prompts', payload: { slug: 'my-custom-prompt', name: 'My Custom Prompt', systemPrompt: 'You are a helpful assistant.', userPromptTemplate: 'Do something with: {{noteBody}}', category: 'transform', inputType: 'text', outputType: 'replace', }, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.slug).toBe('my-custom-prompt'); expect(body.isBuiltin).toBe(false); expect(body.productId).toBe('notelett'); }); it('PATCH + DELETE custom template lifecycle', async () => { // create const createRes = await app.inject({ method: 'POST', url: '/api/note-prompts', payload: { slug: 'lifecycle-test', name: 'Lifecycle', systemPrompt: 'sys', userPromptTemplate: '{{noteBody}}', }, }); const id = createRes.json().id; // update const patchRes = await app.inject({ method: 'PATCH', url: `/api/note-prompts/${id}`, payload: { name: 'Updated Name' }, }); expect(patchRes.statusCode).toBe(200); expect(patchRes.json().name).toBe('Updated Name'); // delete const delRes = await app.inject({ method: 'DELETE', url: `/api/note-prompts/${id}` }); expect(delRes.statusCode).toBe(204); }); it('PATCH builtin returns 404', async () => { await seedBuiltins(); const res = await app.inject({ method: 'PATCH', url: '/api/note-prompts/builtin-summarize', payload: { name: 'Hacked' }, }); expect(res.statusCode).toBe(404); }); it('DELETE builtin returns 404', async () => { await seedBuiltins(); const res = await app.inject({ method: 'DELETE', url: '/api/note-prompts/builtin-summarize' }); expect(res.statusCode).toBe(404); }); }); describe('note-prompts run', () => { beforeEach(() => { resetMemoryDatastore(); }); it('POST /note-prompts/run — runs builtin template', async () => { await seedBuiltins(); // Create a note first const noteRes = await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'n1', workspaceId: 'ws1', title: 'Test', body: 'Test content about AI.' }, }); expect(noteRes.statusCode).toBe(201); const res = await app.inject({ method: 'POST', url: '/api/note-prompts/run', payload: { templateId: 'builtin-summarize', noteId: 'n1', workspaceId: 'ws1' }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.content).toBeTruthy(); expect(body.templateSlug).toBe('summarize'); expect(body.outputType).toBe('new_note'); expect(body.usage).toBeDefined(); }); it('POST /note-prompts/run — resolves by slug', async () => { await seedBuiltins(); await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'n2', workspaceId: 'ws2', title: 'Test', body: 'Bullet content.' }, }); const res = await app.inject({ method: 'POST', url: '/api/note-prompts/run', payload: { templateId: 'bulletize', noteId: 'n2', workspaceId: 'ws2' }, }); expect(res.statusCode).toBe(200); expect(res.json().templateSlug).toBe('bulletize'); }); it('POST /note-prompts/run — 404 for unknown template', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'n3', workspaceId: 'ws3', title: 'T', body: 'B' }, }); const res = await app.inject({ method: 'POST', url: '/api/note-prompts/run', payload: { templateId: 'nonexistent', noteId: 'n3', workspaceId: 'ws3' }, }); expect(res.statusCode).toBe(404); }); it('POST /note-prompts/run — 404 for unknown note', async () => { await seedBuiltins(); const res = await app.inject({ method: 'POST', url: '/api/note-prompts/run', payload: { templateId: 'builtin-summarize', noteId: 'nonexistent', workspaceId: 'ws-x' }, }); expect(res.statusCode).toBe(404); }); }); describe('reading-time', () => { beforeEach(() => { resetMemoryDatastore(); }); it('GET /notes/:id/reading-time — returns word count and reading time', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'rt1', workspaceId: 'ws-rt', title: 'Long', body: Array(500).fill('word').join(' ') }, }); const res = await app.inject({ method: 'GET', url: '/api/notes/rt1/reading-time?workspaceId=ws-rt', }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.wordCount).toBe(500); expect(body.readingTimeMinutes).toBe(3); // ceil(500/238) }); it('GET /notes/:id/reading-time — 400 without workspaceId', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'rt2', workspaceId: 'ws-rt2', title: 'X', body: 'Y' }, }); const res = await app.inject({ method: 'GET', url: '/api/notes/rt2/reading-time' }); expect(res.statusCode).toBe(400); }); }); describe('seed', () => { it('getBuiltinTemplates returns 27 templates', () => { const templates = getBuiltinTemplates(); expect(templates.length).toBe(27); expect(templates.every((t) => t.isBuiltin)).toBe(true); expect(templates.every((t) => t.id.startsWith('builtin-'))).toBe(true); }); it('all slugs are unique', () => { const templates = getBuiltinTemplates(); const slugs = templates.map((t) => t.slug); expect(new Set(slugs).size).toBe(slugs.length); }); it('all categories are valid', () => { const validCategories = ['transform', 'extract', 'generate', 'analyze', 'export']; const templates = getBuiltinTemplates(); expect(templates.every((t) => validCategories.includes(t.category))).toBe(true); }); });