learning_ai_notes/backend/src/lib/note-hooks.test.ts

152 lines
4.5 KiB
TypeScript

/**
* 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 {
const base: NoteDoc = {
id: 'note-1',
productId: 'notelett',
userId: 'user-1',
workspaceId: 'ws-1',
title: 'Test Note',
body: 'Some body text for testing purposes.',
status: 'active',
tags: [],
links: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
createdBy: 'user-1',
updatedBy: 'user-1',
};
return Object.assign(base, 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();
});
});