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