Skip to main content
Glama
searchEngine.test.ts19.1 kB
/** * Tests for search engine service */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { searchNotes, getRecentNotes } from '../searchEngine.js'; import type { SearchFilters } from '../../types/session.js'; vi.mock('../storage.js', () => ({ getAllNoteFiles: vi.fn(), loadNoteMetadata: vi.fn(), readNote: vi.fn(), })); import { getAllNoteFiles, loadNoteMetadata, readNote } from '../storage.js'; describe('searchNotes', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('basic search', () => { it('returns notes matching query', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'OAuth authentication', timestamp: '2024-01-01T00:00:00Z', }); vi.mocked(readNote).mockResolvedValue('OAuth authentication implementation'); const filters: SearchFilters = { query: 'oauth' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); expect(results[0].note.summary).toBe('OAuth authentication'); }); it('performs case-insensitive search', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'OAuth Authentication', timestamp: '2024-01-01T00:00:00Z', }); vi.mocked(readNote).mockResolvedValue('OAuth Authentication'); const filters: SearchFilters = { query: 'oauth' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); }); it('searches in markdown content', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'OAuth implementation work', timestamp: '2024-01-01T00:00:00Z', projectName: 'test', }); vi.mocked(readNote).mockResolvedValue('Content with OAuth implementation details'); const filters: SearchFilters = { query: 'oauth' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); }); it('excludes notes not matching query', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'OAuth work', timestamp: '2024-01-01T00:00:00Z', }) .mockResolvedValueOnce({ summary: 'CSS styling', timestamp: '2024-01-02T00:00:00Z', }); vi.mocked(readNote) .mockResolvedValueOnce('OAuth implementation') .mockResolvedValueOnce('CSS styling work'); const filters: SearchFilters = { query: 'oauth' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); expect(results[0].note.summary).toBe('OAuth work'); }); }); describe('project name filter', () => { it('filters by project name', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', projectName: 'my-app', }) .mockResolvedValueOnce({ summary: 'Work', timestamp: '2024-01-02T00:00:00Z', projectName: 'other-app', }); const filters: SearchFilters = { projectName: 'my-app' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); expect(results[0].note.projectName).toBe('my-app'); }); it('excludes notes with different project', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', projectName: 'other-app', }); const filters: SearchFilters = { projectName: 'my-app' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(0); }); }); describe('pattern filter', () => { it('filters by session pattern', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'Bug fix', timestamp: '2024-01-01T00:00:00Z', analysis: { pattern: 'bug-fix', patternConfidence: 90, complexity: 'simple', fileCount: 20, keyFiles: [], }, }); const filters: SearchFilters = { pattern: 'bug-fix' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); }); it('excludes notes with different pattern', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'Feature', timestamp: '2024-01-01T00:00:00Z', analysis: { pattern: 'new-feature', patternConfidence: 85, complexity: 'moderate', fileCount: 50, keyFiles: [], }, }); const filters: SearchFilters = { pattern: 'bug-fix' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(0); }); }); describe('complexity filter', () => { it('filters by complexity level', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', analysis: { pattern: 'new-feature', patternConfidence: 80, complexity: 'complex', fileCount: 75, keyFiles: [], }, }); const filters: SearchFilters = { complexity: 'complex' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); }); }); describe('date range filter', () => { it('filters by start date', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'Recent', timestamp: '2024-01-15T00:00:00Z', }) .mockResolvedValueOnce({ summary: 'Old', timestamp: '2024-01-01T00:00:00Z', }); const filters: SearchFilters = { startDate: '2024-01-10T00:00:00Z' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); expect(results[0].note.summary).toBe('Recent'); }); it('filters by end date', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'Old', timestamp: '2024-01-05T00:00:00Z', }) .mockResolvedValueOnce({ summary: 'Recent', timestamp: '2024-01-20T00:00:00Z', }); const filters: SearchFilters = { endDate: '2024-01-10T00:00:00Z' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); expect(results[0].note.summary).toBe('Old'); }); it('filters by date range', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', '/notes/session3.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'Before', timestamp: '2024-01-01T00:00:00Z', }) .mockResolvedValueOnce({ summary: 'During', timestamp: '2024-01-15T00:00:00Z', }) .mockResolvedValueOnce({ summary: 'After', timestamp: '2024-01-30T00:00:00Z', }); const filters: SearchFilters = { startDate: '2024-01-10T00:00:00Z', endDate: '2024-01-20T00:00:00Z', }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); expect(results[0].note.summary).toBe('During'); }); }); describe('tags filter', () => { it('filters by tags (note must have at least one)', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'Match', timestamp: '2024-01-01T00:00:00Z', tags: ['auth', 'security'], }) .mockResolvedValueOnce({ summary: 'No match', timestamp: '2024-01-02T00:00:00Z', tags: ['ui', 'design'], }); const filters: SearchFilters = { tags: ['auth', 'database'] }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); expect(results[0].note.summary).toBe('Match'); }); it('excludes notes with no tags', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'No tags', timestamp: '2024-01-01T00:00:00Z', }); const filters: SearchFilters = { tags: ['auth'] }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(0); }); }); describe('relevance scoring', () => { it('calculates relevance score for each result', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'OAuth auth', timestamp: '2024-01-01T00:00:00Z', tags: ['auth'], }); vi.mocked(readNote).mockResolvedValue('OAuth implementation'); const filters: SearchFilters = { query: 'oauth', tags: ['auth'] }; const results = await searchNotes('/notes', filters); expect(results[0].relevanceScore).toBeGreaterThan(0); }); it('sorts results by relevance (highest first)', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', '/notes/session3.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'OAuth', timestamp: '2024-01-01T00:00:00Z', tags: ['auth'], }) .mockResolvedValueOnce({ summary: 'OAuth OAuth OAuth', timestamp: '2024-01-02T00:00:00Z', tags: ['auth'], }) .mockResolvedValueOnce({ summary: 'OAuth work', timestamp: '2024-01-03T00:00:00Z', }); vi.mocked(readNote) .mockResolvedValueOnce('OAuth') .mockResolvedValueOnce('OAuth OAuth OAuth') .mockResolvedValueOnce('OAuth work'); const filters: SearchFilters = { query: 'oauth' }; const results = await searchNotes('/notes', filters); // Verify sorted by descending relevance for (let i = 1; i < results.length; i++) { expect(results[i - 1].relevanceScore).toBeGreaterThanOrEqual( results[i].relevanceScore ); } }); it('boosts exact project match', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', projectName: 'my-app', tags: ['test'], }) .mockResolvedValueOnce({ summary: 'Work', timestamp: '2024-01-02T00:00:00Z', tags: ['test'], }); const filters: SearchFilters = { projectName: 'my-app', tags: ['test'] }; const results = await searchNotes('/notes', filters); // Project match should have higher score expect(results[0].note.projectName).toBe('my-app'); }); it('boosts tag matches', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', tags: ['auth', 'security'], }) .mockResolvedValueOnce({ summary: 'Work', timestamp: '2024-01-02T00:00:00Z', tags: ['auth'], }); const filters: SearchFilters = { tags: ['auth', 'security'] }; const results = await searchNotes('/notes', filters); // More matching tags = higher score expect(results[0].note.tags).toContain('security'); }); it('includes recency boost', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'OAuth', timestamp: new Date().toISOString(), // Recent }) .mockResolvedValueOnce({ summary: 'OAuth', timestamp: '2020-01-01T00:00:00Z', // Old }); vi.mocked(readNote) .mockResolvedValueOnce('OAuth') .mockResolvedValueOnce('OAuth'); const filters: SearchFilters = { query: 'oauth' }; const results = await searchNotes('/notes', filters); // Recent note should have slightly higher score expect(new Date(results[0].note.timestamp).getTime()).toBeGreaterThan( new Date(results[1].note.timestamp).getTime() ); }); }); describe('edge cases', () => { it('handles empty filters', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', }); const filters: SearchFilters = {}; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); }); it('handles no matches', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue(['/notes/session1.md']); vi.mocked(loadNoteMetadata).mockResolvedValue({ summary: 'CSS work', timestamp: '2024-01-01T00:00:00Z', }); vi.mocked(readNote).mockResolvedValue('CSS styling'); const filters: SearchFilters = { query: 'oauth' }; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(0); }); it('skips notes with missing metadata', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce(null) .mockResolvedValueOnce({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', }); const filters: SearchFilters = {}; const results = await searchNotes('/notes', filters); expect(results).toHaveLength(1); }); it('handles empty notes directory', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([]); const filters: SearchFilters = { query: 'test' }; const results = await searchNotes('/notes', filters); expect(results).toEqual([]); }); }); }); describe('getRecentNotes', () => { beforeEach(() => { vi.clearAllMocks(); }); it('returns most recent notes first', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', '/notes/session3.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'Old', timestamp: '2024-01-01T00:00:00Z', }) .mockResolvedValueOnce({ summary: 'Recent', timestamp: '2024-01-15T00:00:00Z', }) .mockResolvedValueOnce({ summary: 'Middle', timestamp: '2024-01-10T00:00:00Z', }); const results = await getRecentNotes('/notes'); expect(results[0].summary).toBe('Recent'); expect(results[1].summary).toBe('Middle'); expect(results[2].summary).toBe('Old'); }); it('respects limit parameter', async () => { const sessionFiles = Array(20) .fill(null) .map((_, i) => `/notes/session${i}.md`); vi.mocked(getAllNoteFiles).mockResolvedValue(sessionFiles); vi.mocked(loadNoteMetadata).mockImplementation(async () => ({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', })); const results = await getRecentNotes('/notes', 5); expect(results.length).toBe(5); }); it('uses default limit of 10', async () => { const sessionFiles = Array(20) .fill(null) .map((_, i) => `/notes/session${i}.md`); vi.mocked(getAllNoteFiles).mockResolvedValue(sessionFiles); vi.mocked(loadNoteMetadata).mockImplementation(async () => ({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', })); const results = await getRecentNotes('/notes'); expect(results.length).toBe(10); }); it('skips notes with missing metadata', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce(null) .mockResolvedValueOnce({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', }); const results = await getRecentNotes('/notes'); expect(results).toHaveLength(1); }); it('skips notes without timestamp', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata) .mockResolvedValueOnce({ summary: 'No timestamp', timestamp: undefined as any, }) .mockResolvedValueOnce({ summary: 'With timestamp', timestamp: '2024-01-01T00:00:00Z', }); const results = await getRecentNotes('/notes'); expect(results).toHaveLength(1); expect(results[0].summary).toBe('With timestamp'); }); it('handles empty notes directory', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([]); const results = await getRecentNotes('/notes'); expect(results).toEqual([]); }); it('returns fewer than limit if not enough notes', async () => { vi.mocked(getAllNoteFiles).mockResolvedValue([ '/notes/session1.md', '/notes/session2.md', ]); vi.mocked(loadNoteMetadata).mockImplementation(async () => ({ summary: 'Work', timestamp: '2024-01-01T00:00:00Z', })); const results = await getRecentNotes('/notes', 10); expect(results.length).toBe(2); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/VoCoufi/second-brain-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server