Skip to main content
Glama
full-text-search-engine.test.ts13.9 kB
/** * Unit Tests for FullTextSearchEngine * * Tests PostgreSQL ts_vector-based full-text search with query parsing, * result ranking, highlighting, and caching. * * Requirements: 4.1, 4.2, 4.3, 4.4, 4.5 */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DatabaseConnectionManager } from "../../../database/connection-manager"; import { FullTextSearchEngine } from "../../../search/full-text-search-engine"; import { SearchValidationError } from "../../../search/types"; describe("FullTextSearchEngine", () => { let engine: FullTextSearchEngine; let mockDb: DatabaseConnectionManager; // Helper to create mock clients for each database operation const createMockClient = (queryResult: unknown) => ({ query: vi.fn().mockResolvedValue(queryResult), }); beforeEach(() => { mockDb = { getConnection: vi.fn(), releaseConnection: vi.fn(), } as unknown as DatabaseConnectionManager; engine = new FullTextSearchEngine(mockDb, { enableCache: false }); }); describe("constructor", () => { it("should create engine with default config", () => { const defaultEngine = new FullTextSearchEngine(mockDb); expect(defaultEngine).toBeDefined(); }); it("should create engine with custom config", () => { const customEngine = new FullTextSearchEngine(mockDb, { defaultMaxResults: 50, maxAllowedResults: 500, defaultLanguage: "spanish", enableCache: false, }); expect(customEngine).toBeDefined(); }); }); describe("search", () => { it("should execute full-text search and return results", async () => { const searchClient = createMockClient({ rows: [ { memoryId: "mem1", content: "Test content about machine learning", headline: "<b>machine</b> <b>learning</b> is great", rank: 0.95, createdAt: new Date(), salience: 0.8, strength: 0.9, }, ], }); const countClient = createMockClient({ rows: [{ total: "1" }] }); const explainClient = createMockClient({ rows: [ { "QUERY PLAN": '[{"Plan": {"Node Type": "Index Scan", "Index Name": "idx_memories_search_vector"}}]', }, ], }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); const result = await engine.search({ query: "machine learning" }); expect(result.results).toHaveLength(1); expect(result.results[0].memoryId).toBe("mem1"); expect(result.results[0].rank).toBe(0.95); expect(result.statistics.totalResults).toBe(1); expect(mockDb.getConnection).toHaveBeenCalled(); expect(mockDb.releaseConnection).toHaveBeenCalled(); }); it("should apply userId filter when provided", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [{}] }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); await engine.search({ query: "test", userId: "user123" }); expect(searchClient.query).toHaveBeenCalledWith( expect.stringContaining("user_id"), expect.arrayContaining(["user123"]) ); }); it("should apply minStrength filter when provided", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [{}] }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); await engine.search({ query: "test", minStrength: 0.5 }); expect(searchClient.query).toHaveBeenCalledWith( expect.stringContaining("strength >="), expect.arrayContaining([0.5]) ); }); it("should apply minSalience filter when provided", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [{}] }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); await engine.search({ query: "test", minSalience: 0.6 }); expect(searchClient.query).toHaveBeenCalledWith( expect.stringContaining("salience >="), expect.arrayContaining([0.6]) ); }); it("should use ts_rank_cd when rankingMode is rank_cd", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [{}] }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); await engine.search({ query: "test", rankingMode: "rank_cd" }); expect(searchClient.query).toHaveBeenCalledWith( expect.stringContaining("ts_rank_cd"), expect.any(Array) ); }); it("should use custom language when provided", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [{}] }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); await engine.search({ query: "prueba", language: "spanish" }); expect(searchClient.query).toHaveBeenCalledWith( expect.any(String), expect.arrayContaining(["spanish"]) ); }); it("should apply pagination with offset", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [{}] }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); await engine.search({ query: "test", maxResults: 10, offset: 20 }); expect(searchClient.query).toHaveBeenCalledWith( expect.stringContaining("OFFSET"), expect.arrayContaining([10, 20]) ); }); it("should return empty results when no matches", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [{}] }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); const result = await engine.search({ query: "nonexistent" }); expect(result.results).toHaveLength(0); expect(result.statistics.totalResults).toBe(0); }); }); describe("validation", () => { it("should throw SearchValidationError for empty query", async () => { await expect(engine.search({ query: "" })).rejects.toThrow(SearchValidationError); }); it("should throw SearchValidationError for maxResults less than 1", async () => { await expect(engine.search({ query: "test", maxResults: 0 })).rejects.toThrow( SearchValidationError ); }); it("should throw SearchValidationError for maxResults exceeding limit", async () => { await expect(engine.search({ query: "test", maxResults: 10001 })).rejects.toThrow( SearchValidationError ); }); it("should throw SearchValidationError for negative offset", async () => { await expect(engine.search({ query: "test", offset: -1 })).rejects.toThrow( SearchValidationError ); }); it("should throw SearchValidationError for minStrength out of range", async () => { await expect(engine.search({ query: "test", minStrength: -0.1 })).rejects.toThrow( SearchValidationError ); await expect(engine.search({ query: "test", minStrength: 1.1 })).rejects.toThrow( SearchValidationError ); }); it("should throw SearchValidationError for minSalience out of range", async () => { await expect(engine.search({ query: "test", minSalience: -0.1 })).rejects.toThrow( SearchValidationError ); await expect(engine.search({ query: "test", minSalience: 1.1 })).rejects.toThrow( SearchValidationError ); }); }); describe("caching", () => { it("should return cached results on subsequent searches", async () => { const cachedEngine = new FullTextSearchEngine(mockDb, { enableCache: true }); const searchClient = createMockClient({ rows: [ { memoryId: "mem1", content: "Test content", headline: "<b>Test</b> content", rank: 0.9, createdAt: new Date(), salience: 0.8, strength: 0.9, }, ], }); const countClient = createMockClient({ rows: [{ total: "1" }] }); const explainClient = createMockClient({ rows: [{}] }); // For caching full result set const cacheSearchClient = createMockClient({ rows: [ { memoryId: "mem1", content: "Test content", headline: "<b>Test</b> content", rank: 0.9, createdAt: new Date(), salience: 0.8, strength: 0.9, }, ], }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient) .mockResolvedValueOnce(cacheSearchClient); // First search await cachedEngine.search({ query: "test" }); // Second search should use cache const result = await cachedEngine.search({ query: "test" }); expect(result.results).toHaveLength(1); expect(result.statistics.searchTime).toBe(0); // Cached result }); it("should return cache statistics", () => { const stats = engine.getCacheStats(); expect(stats).toHaveProperty("hits"); expect(stats).toHaveProperty("misses"); expect(stats).toHaveProperty("size"); }); it("should clear cache", () => { engine.clearCache(); const stats = engine.getCacheStats(); expect(stats.size).toBe(0); }); }); describe("error handling", () => { it("should throw SearchQueryError on database failure", async () => { const failingClient = { query: vi.fn().mockRejectedValue(new Error("Database connection failed")), }; (mockDb.getConnection as ReturnType<typeof vi.fn>).mockResolvedValue(failingClient); await expect(engine.search({ query: "test" })).rejects.toThrow("Failed to execute search"); }); it("should re-throw SearchValidationError as-is", async () => { await expect(engine.search({ query: "" })).rejects.toThrow(SearchValidationError); }); }); describe("index verification", () => { it("should detect when GIN index is used", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [ { "QUERY PLAN": '[{"Plan": {"Node Type": "Index Scan", "Index Name": "idx_memories_search_vector"}}]', }, ], }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); const result = await engine.search({ query: "test" }); expect(result.statistics.indexUsed).toBe(true); }); it("should detect when GIN index is not used", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = createMockClient({ rows: [{ "QUERY PLAN": '[{"Plan": {"Node Type": "Seq Scan"}}]' }], }); (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); const result = await engine.search({ query: "test" }); expect(result.statistics.indexUsed).toBe(false); }); it("should handle EXPLAIN query failure gracefully", async () => { const searchClient = createMockClient({ rows: [] }); const countClient = createMockClient({ rows: [{ total: "0" }] }); const explainClient = { query: vi.fn().mockRejectedValue(new Error("EXPLAIN failed")) }; (mockDb.getConnection as ReturnType<typeof vi.fn>) .mockResolvedValueOnce(searchClient) .mockResolvedValueOnce(countClient) .mockResolvedValueOnce(explainClient); const result = await engine.search({ query: "test" }); expect(result.statistics.indexUsed).toBe(false); }); }); });

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/keyurgolani/ThoughtMcp'

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