test(backend): add runner, reading-time, copilot-transform, note-hooks tests (G6-G9)

This commit is contained in:
saravanakumardb1 2026-04-06 13:29:33 -07:00
parent 093da76eee
commit 2a7cfbb73e
4 changed files with 507 additions and 0 deletions

View File

@ -0,0 +1,111 @@
/**
* Copilot transform tests LLM-powered + fallback heuristics.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockChatCompletion = vi.fn();
const mockIsConfigured = vi.fn(() => true);
vi.mock('./llm.js', () => ({
llm: () => ({
chatCompletion: mockChatCompletion,
isConfigured: mockIsConfigured,
}),
}));
vi.mock('./telemetry.js', () => ({
trackEvent: vi.fn(),
}));
import { runCopilotTransform, suggestTitleFromBody } from './copilot-transform.js';
describe('runCopilotTransform', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsConfigured.mockReturnValue(true);
mockChatCompletion.mockResolvedValue({
content: 'LLM result text',
model: 'gpt-4o-mini',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
});
});
it('returns LLM output for shorten action', async () => {
const result = await runCopilotTransform('shorten', 'Some long text here');
expect(result).toBe('LLM result text');
expect(mockChatCompletion).toHaveBeenCalledTimes(1);
});
it('falls back to heuristic when LLM is not configured', async () => {
mockIsConfigured.mockReturnValue(false);
const result = await runCopilotTransform('bulletize', 'Line one\nLine two');
expect(result).toContain('- Line one');
expect(result).toContain('- Line two');
});
it('falls back to heuristic when LLM returns empty', async () => {
mockChatCompletion.mockResolvedValue({
content: '',
model: 'gpt-4o-mini',
usage: { promptTokens: 5, completionTokens: 0, totalTokens: 5 },
});
const result = await runCopilotTransform('bulletize', 'Line one\nLine two');
expect(result).toContain('- Line one');
});
it('falls back on LLM error', async () => {
mockChatCompletion.mockRejectedValue(new Error('API down'));
const result = await runCopilotTransform('shorten', 'Some words here to shorten down significantly');
// Fallback shorten truncates
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('explain fallback returns informative message', async () => {
mockIsConfigured.mockReturnValue(false);
const result = await runCopilotTransform('explain', 'quantum entanglement');
expect(result).toContain('not available');
});
it('fix-rewrite fallback returns original text', async () => {
mockIsConfigured.mockReturnValue(false);
const result = await runCopilotTransform('fix-rewrite', 'original text');
expect(result).toBe('original text');
});
});
describe('suggestTitleFromBody', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsConfigured.mockReturnValue(true);
mockChatCompletion.mockResolvedValue({
content: 'Suggested Title',
model: 'gpt-4o-mini',
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
});
});
it('returns LLM-suggested title', async () => {
const title = await suggestTitleFromBody('<p>Some body content here.</p>');
expect(title).toBe('Suggested Title');
});
it('falls back to first sentence when LLM unavailable', async () => {
mockIsConfigured.mockReturnValue(false);
const title = await suggestTitleFromBody('First sentence here. Second sentence.');
expect(title).toBe('First sentence here');
});
it('strips HTML before processing', async () => {
mockIsConfigured.mockReturnValue(false);
const title = await suggestTitleFromBody('<h1>My Title</h1><p>Body text.</p>');
expect(title).toBe('My Title Body text');
});
it('returns "Untitled note" for empty body', async () => {
mockIsConfigured.mockReturnValue(false);
const title = await suggestTitleFromBody('');
expect(title).toBe('Untitled note');
});
});

View File

@ -0,0 +1,150 @@
/**
* Note lifecycle hooks tests background embed + auto-summarize.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock feature flags
const mockFlags: Record<string, boolean> = {};
vi.mock('./feature-flags.js', () => ({
isFeatureEnabled: (flag: string) => mockFlags[flag] ?? false,
}));
// Mock embeddings
const mockEmbedText = vi.fn();
vi.mock('./embeddings.js', () => ({
embedText: (...args: unknown[]) => mockEmbedText(...args),
stripHtmlForEmbedding: (html: string) =>
html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(),
}));
// Mock LLM
const mockChatCompletion = vi.fn();
vi.mock('./llm.js', () => ({
llm: () => ({
chatCompletion: mockChatCompletion,
}),
}));
// Mock datastore
const mockFindById = vi.fn();
const mockUpsert = vi.fn();
vi.mock('./datastore.js', () => ({
getCollection: () => ({
findById: mockFindById,
upsert: mockUpsert,
}),
}));
// Mock artifact/action repos (lazy imports in note-hooks)
vi.mock('../modules/note-artifacts/repository.js', () => ({
createNoteArtifact: vi.fn().mockResolvedValue({ id: 'artifact-1' }),
}));
vi.mock('../modules/note-agent-actions/repository.js', () => ({
createNoteAgentAction: vi.fn().mockResolvedValue({ id: 'action-1' }),
}));
import { runPostSaveHooks } from './note-hooks.js';
import type { NoteDoc } from '../modules/notes/types.js';
const mockLog = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as import('fastify').FastifyBaseLogger;
function makeNote(overrides: Partial<NoteDoc> = {}): NoteDoc {
return {
id: 'note-1',
productId: 'notelett',
userId: 'user-1',
workspaceId: 'ws-1',
title: 'Test Note',
body: 'Some body text for testing purposes.',
status: 'active',
tags: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
createdBy: 'user-1',
updatedBy: 'user-1',
...overrides,
};
}
describe('runPostSaveHooks', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.keys(mockFlags).forEach((k) => delete mockFlags[k]);
});
it('does nothing when all flags are disabled', () => {
runPostSaveHooks(makeNote(), mockLog);
// No errors, no embedding calls
expect(mockEmbedText).not.toHaveBeenCalled();
expect(mockChatCompletion).not.toHaveBeenCalled();
});
it('computes embedding when auto_embed flag is enabled', async () => {
mockFlags['notelett_auto_embed_enabled'] = true;
mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]);
mockFindById.mockResolvedValue(makeNote());
mockUpsert.mockResolvedValue(makeNote());
runPostSaveHooks(makeNote(), mockLog);
// Wait for fire-and-forget promise
await new Promise((r) => setTimeout(r, 50));
expect(mockEmbedText).toHaveBeenCalled();
});
it('skips embedding for very short text', async () => {
mockFlags['notelett_auto_embed_enabled'] = true;
const note = makeNote({ body: 'Hi' }); // < 20 chars
runPostSaveHooks(note, mockLog);
await new Promise((r) => setTimeout(r, 50));
expect(mockEmbedText).not.toHaveBeenCalled();
});
it('skips auto-summarize for short notes', async () => {
mockFlags['notelett_auto_summarize_enabled'] = true;
const note = makeNote({ body: 'Short note.' }); // < 300 words
runPostSaveHooks(note, mockLog);
await new Promise((r) => setTimeout(r, 50));
expect(mockChatCompletion).not.toHaveBeenCalled();
});
it('skips auto-summarize if summary already exists', async () => {
mockFlags['notelett_auto_summarize_enabled'] = true;
const longBody = Array.from({ length: 400 }, (_, i) => `word${i}`).join(' ');
const note = makeNote({ body: longBody, summaryArtifactId: 'existing-summary' });
runPostSaveHooks(note, mockLog);
await new Promise((r) => setTimeout(r, 50));
expect(mockChatCompletion).not.toHaveBeenCalled();
});
it('generates auto-summary for long notes', async () => {
mockFlags['notelett_auto_summarize_enabled'] = true;
const longBody = Array.from({ length: 400 }, (_, i) => `word${i}`).join(' ');
const note = makeNote({ body: longBody });
mockChatCompletion.mockResolvedValue({
content: 'Auto generated summary.',
model: 'gpt-4o-mini',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
});
mockFindById.mockResolvedValue(note);
mockUpsert.mockResolvedValue(note);
runPostSaveHooks(note, mockLog);
await new Promise((r) => setTimeout(r, 100));
expect(mockChatCompletion).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,44 @@
/**
* Reading time estimation tests.
*/
import { describe, it, expect } from 'vitest';
import { estimateReadingTime } from './reading-time.js';
describe('estimateReadingTime', () => {
it('returns 1 minute minimum for empty content', () => {
const result = estimateReadingTime('');
expect(result.minutes).toBe(1);
expect(result.words).toBe(0);
});
it('returns 1 minute for short text', () => {
const result = estimateReadingTime('Hello world');
expect(result.minutes).toBe(1);
expect(result.words).toBe(2);
});
it('strips HTML tags before counting', () => {
const result = estimateReadingTime('<p>Hello <strong>world</strong></p>');
expect(result.words).toBe(2);
});
it('calculates correct minutes for long text (~238 wpm)', () => {
const words = Array.from({ length: 476 }, (_, i) => `word${i}`).join(' ');
const result = estimateReadingTime(words);
expect(result.words).toBe(476);
expect(result.minutes).toBe(2);
});
it('rounds up reading time', () => {
const words = Array.from({ length: 239 }, (_, i) => `word${i}`).join(' ');
const result = estimateReadingTime(words);
expect(result.words).toBe(239);
expect(result.minutes).toBe(2); // 239/238 = 1.004 → ceil = 2
});
it('handles whitespace-heavy content correctly', () => {
const result = estimateReadingTime(' one two three ');
expect(result.words).toBe(3);
});
});

View File

@ -0,0 +1,202 @@
/**
* Runner tests executePrompt() logic.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock @bytelyst/llm
vi.mock('@bytelyst/llm', () => ({
buildVisionMessage: vi.fn((text: string, imageUrl: string) => ({
role: 'user',
content: [
{ type: 'text', text },
{ type: 'image_url', image_url: { url: imageUrl } },
],
})),
hasVisionContent: vi.fn((req: { messages: Array<{ content: unknown }> }) =>
req.messages.some(
(m) => Array.isArray(m.content) && m.content.some((c: { type: string }) => c.type === 'image_url'),
),
),
}));
// Mock llm singleton
const mockChatCompletion = vi.fn();
const mockIsConfigured = vi.fn(() => true);
vi.mock('../../lib/llm.js', () => ({
llm: () => ({
chatCompletion: mockChatCompletion,
isConfigured: mockIsConfigured,
}),
}));
// Mock config
vi.mock('../../lib/config.js', () => ({
config: {
LLM_DEFAULT_MODEL: 'gpt-4o-mini',
LLM_VISION_MODEL: 'gpt-4o',
},
}));
// Mock telemetry
vi.mock('../../lib/telemetry.js', () => ({
trackEvent: vi.fn(),
}));
import { executePrompt } from './runner.js';
import type { PromptTemplateDoc, RunPromptInput } from './types.js';
function makeTemplate(overrides: Partial<PromptTemplateDoc> = {}): PromptTemplateDoc {
return {
id: 'tpl-1',
productId: 'notelett',
userId: '__builtin__',
slug: 'summarize',
name: 'Summarize',
description: 'Summarize',
systemPrompt: 'You are a summarizer.',
userPromptTemplate: 'Summarize:\n\n{{noteBody}}',
inputType: 'text',
outputType: 'new_note',
category: 'transform',
isBuiltin: true,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
function makeInput(overrides: Partial<RunPromptInput> = {}): RunPromptInput {
return {
templateId: 'tpl-1',
noteId: 'note-1',
workspaceId: 'ws-1',
...overrides,
};
}
describe('executePrompt', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsConfigured.mockReturnValue(true);
mockChatCompletion.mockResolvedValue({
content: 'Summary result',
model: 'gpt-4o-mini',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
});
});
it('returns content from LLM for text-only prompt', async () => {
const result = await executePrompt(makeTemplate(), makeInput(), 'Some note body');
expect(result.content).toBe('Summary result');
expect(result.templateSlug).toBe('summarize');
expect(result.outputType).toBe('new_note');
expect(result.model).toBe('gpt-4o-mini');
expect(result.usage.totalTokens).toBe(30);
});
it('throws when LLM is not configured', async () => {
mockIsConfigured.mockReturnValue(false);
await expect(executePrompt(makeTemplate(), makeInput(), 'body')).rejects.toThrow(
'LLM provider is not configured',
);
});
it('throws when LLM returns empty response', async () => {
mockChatCompletion.mockResolvedValue({
content: '',
model: 'gpt-4o-mini',
usage: { promptTokens: 5, completionTokens: 0, totalTokens: 5 },
});
await expect(executePrompt(makeTemplate(), makeInput(), 'body')).rejects.toThrow(
'LLM returned empty response',
);
});
it('interpolates {{noteBody}} in user prompt', async () => {
await executePrompt(makeTemplate(), makeInput(), 'My note text');
const call = mockChatCompletion.mock.calls[0][0];
expect(call.messages[1].content).toContain('My note text');
});
it('interpolates custom {{variables}}', async () => {
const tpl = makeTemplate({ userPromptTemplate: 'Lang: {{language}}\n{{noteBody}}' });
const input = makeInput({ variables: { language: 'Spanish' } });
await executePrompt(tpl, input, 'Hello');
const call = mockChatCompletion.mock.calls[0][0];
expect(call.messages[1].content).toContain('Spanish');
});
it('uses vision model for image templates', async () => {
const tpl = makeTemplate({ inputType: 'image' });
const input = makeInput({ imageUrl: 'https://example.com/img.png' });
await executePrompt(tpl, input, 'body');
const call = mockChatCompletion.mock.calls[0][0];
expect(call.model).toBe('gpt-4o');
});
it('applies approval state for approval-gated templates', async () => {
const tpl = makeTemplate({ requiresApproval: true });
const result = await executePrompt(tpl, makeInput(), 'body');
expect(result.approvalState).toBe('proposed');
});
it('applies "applied" state for non-approval templates', async () => {
const result = await executePrompt(makeTemplate(), makeInput(), 'body');
expect(result.approvalState).toBe('applied');
});
it('respects template temperature and maxTokens', async () => {
const tpl = makeTemplate({ temperature: 0.1, maxTokens: 256 });
await executePrompt(tpl, makeInput(), 'body');
const call = mockChatCompletion.mock.calls[0][0];
expect(call.temperature).toBe(0.1);
expect(call.maxTokens).toBe(256);
});
it('retries on rate limit (429) error', async () => {
mockChatCompletion
.mockRejectedValueOnce(new Error('429 rate limit'))
.mockResolvedValueOnce({
content: 'Retry success',
model: 'gpt-4o-mini',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
});
const result = await executePrompt(makeTemplate(), makeInput(), 'body');
expect(result.content).toBe('Retry success');
expect(mockChatCompletion).toHaveBeenCalledTimes(2);
});
it('throws on non-retriable error', async () => {
mockChatCompletion.mockRejectedValue(new Error('Invalid API key'));
await expect(executePrompt(makeTemplate(), makeInput(), 'body')).rejects.toThrow('Invalid API key');
expect(mockChatCompletion).toHaveBeenCalledTimes(1);
});
it('uses custom model from template if provided', async () => {
const tpl = makeTemplate({ model: 'gpt-4-turbo' });
await executePrompt(tpl, makeInput(), 'body');
const call = mockChatCompletion.mock.calls[0][0];
expect(call.model).toBe('gpt-4-turbo');
});
it('includes inputText in variables when provided', async () => {
const tpl = makeTemplate({ userPromptTemplate: '{{inputText}}\n{{noteBody}}' });
const input = makeInput({ inputText: 'custom override prompt' });
await executePrompt(tpl, input, 'body');
const call = mockChatCompletion.mock.calls[0][0];
expect(call.messages[1].content).toContain('custom override prompt');
});
});