test(backend): add runner, reading-time, copilot-transform, note-hooks tests (G6-G9)
This commit is contained in:
parent
093da76eee
commit
2a7cfbb73e
111
backend/src/lib/copilot-transform.test.ts
Normal file
111
backend/src/lib/copilot-transform.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
150
backend/src/lib/note-hooks.test.ts
Normal file
150
backend/src/lib/note-hooks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
44
backend/src/lib/reading-time.test.ts
Normal file
44
backend/src/lib/reading-time.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
202
backend/src/modules/note-prompts/runner.test.ts
Normal file
202
backend/src/modules/note-prompts/runner.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user