- Add @bytelyst/llm dependency (file: ref) + llm.ts singleton wrapper - Add LLM env vars to config (LLM_PROVIDER, LLM_DEFAULT_MODEL, LLM_VISION_MODEL, LLM_EMBEDDING_MODEL) - Create note-prompts module: types, repository, runner, routes, seed (20 built-in templates) - Built-in templates: 8 transform, 3 extract, 3 generate, 2 analyze, 2 vision, 2 export - Prompt runner supports text, image, and text+image inputs via @bytelyst/llm vision - Upgrade copilot-transform.ts to use @bytelyst/llm directly (with local heuristic fallback) - Add reading-time endpoint (GET /api/notes/:id/reading-time) - Extend agent-action types with smart_action and auto_enrich - Add note_prompts Cosmos container to cosmos-init - Register notePromptRoutes in server.ts - 15 new tests (CRUD, run, slug resolution, seed validation, reading-time)
294 lines
9.2 KiB
TypeScript
294 lines
9.2 KiB
TypeScript
/**
|
|
* 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 20 templates', () => {
|
|
const templates = getBuiltinTemplates();
|
|
expect(templates.length).toBe(20);
|
|
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);
|
|
});
|
|
});
|