/**
* 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);
});
});