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