diff --git a/backend/src/lib/copilot-transform.test.ts b/backend/src/lib/copilot-transform.test.ts new file mode 100644 index 0000000..fb5c196 --- /dev/null +++ b/backend/src/lib/copilot-transform.test.ts @@ -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('

Some body content here.

'); + 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('

My Title

Body text.

'); + 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'); + }); +}); diff --git a/backend/src/lib/note-hooks.test.ts b/backend/src/lib/note-hooks.test.ts new file mode 100644 index 0000000..e7a6e86 --- /dev/null +++ b/backend/src/lib/note-hooks.test.ts @@ -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 = {}; +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 { + 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(); + }); +}); diff --git a/backend/src/lib/reading-time.test.ts b/backend/src/lib/reading-time.test.ts new file mode 100644 index 0000000..1719446 --- /dev/null +++ b/backend/src/lib/reading-time.test.ts @@ -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('

Hello world

'); + 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); + }); +}); diff --git a/backend/src/modules/note-prompts/runner.test.ts b/backend/src/modules/note-prompts/runner.test.ts new file mode 100644 index 0000000..93c6fc5 --- /dev/null +++ b/backend/src/modules/note-prompts/runner.test.ts @@ -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 { + 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 { + 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'); + }); +});