import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { EntryStatus } from "@prisma/client"; import { SearchService } from "./search.service"; import { PrismaService } from "../../prisma/prisma.service"; import { KnowledgeCacheService } from "./cache.service"; import { OllamaEmbeddingService } from "./ollama-embedding.service"; describe("SearchService", () => { let service: SearchService; let prismaService: any; const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440000"; beforeEach(async () => { const mockQueryRaw = vi.fn(); const mockKnowledgeEntryCount = vi.fn(); const mockKnowledgeEntryFindMany = vi.fn(); const mockKnowledgeEntryTagFindMany = vi.fn(); const mockPrismaService = { $queryRaw: mockQueryRaw, knowledgeEntry: { count: mockKnowledgeEntryCount, findMany: mockKnowledgeEntryFindMany, }, knowledgeEntryTag: { findMany: mockKnowledgeEntryTagFindMany, }, }; const mockCacheService = { getEntry: vi.fn().mockResolvedValue(null), setEntry: vi.fn().mockResolvedValue(undefined), invalidateEntry: vi.fn().mockResolvedValue(undefined), getSearch: vi.fn().mockResolvedValue(null), setSearch: vi.fn().mockResolvedValue(undefined), invalidateSearches: vi.fn().mockResolvedValue(undefined), getGraph: vi.fn().mockResolvedValue(null), setGraph: vi.fn().mockResolvedValue(undefined), invalidateGraphs: vi.fn().mockResolvedValue(undefined), invalidateGraphsForEntry: vi.fn().mockResolvedValue(undefined), clearWorkspaceCache: vi.fn().mockResolvedValue(undefined), getStats: vi.fn().mockReturnValue({ hits: 0, misses: 0, sets: 0, deletes: 0, hitRate: 0 }), resetStats: vi.fn(), isEnabled: vi.fn().mockReturnValue(false), }; const mockOllamaEmbeddingService = { isConfigured: vi.fn().mockResolvedValue(false), generateEmbedding: vi.fn().mockResolvedValue([]), generateAndStoreEmbedding: vi.fn().mockResolvedValue(undefined), batchGenerateEmbeddings: vi.fn().mockResolvedValue(0), }; const module: TestingModule = await Test.createTestingModule({ providers: [ SearchService, { provide: PrismaService, useValue: mockPrismaService, }, { provide: KnowledgeCacheService, useValue: mockCacheService, }, { provide: OllamaEmbeddingService, useValue: mockOllamaEmbeddingService, }, ], }).compile(); service = module.get(SearchService); prismaService = module.get(PrismaService); }); describe("search", () => { it("should return empty results for empty query", async () => { const result = await service.search("", mockWorkspaceId); expect(result.data).toEqual([]); expect(result.pagination.total).toBe(0); expect(result.query).toBe(""); }); it("should return empty results for whitespace-only query", async () => { const result = await service.search(" ", mockWorkspaceId); expect(result.data).toEqual([]); expect(result.pagination.total).toBe(0); }); it("should perform full-text search and return ranked results", async () => { const mockSearchResults = [ { id: "entry-1", workspace_id: mockWorkspaceId, slug: "test-entry", title: "Test Entry", content: "This is test content", content_html: "

This is test content

", summary: "Test summary", status: EntryStatus.PUBLISHED, visibility: "WORKSPACE", created_at: new Date(), updated_at: new Date(), created_by: "user-1", updated_by: "user-1", rank: 0.5, headline: "This is test content", }, ]; prismaService.$queryRaw .mockResolvedValueOnce(mockSearchResults) .mockResolvedValueOnce([{ count: BigInt(1) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([ { entryId: "entry-1", tag: { id: "tag-1", name: "Documentation", slug: "documentation", color: "#blue", }, }, ]); const result = await service.search("test", mockWorkspaceId); expect(result.data).toHaveLength(1); expect(result.data[0].title).toBe("Test Entry"); expect(result.data[0].rank).toBe(0.5); expect(result.data[0].headline).toBe("This is test content"); expect(result.data[0].tags).toHaveLength(1); expect(result.pagination.total).toBe(1); expect(result.query).toBe("test"); }); it("should sanitize search query removing special characters", async () => { prismaService.$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(0) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); await service.search("test & query | !special:chars*", mockWorkspaceId); // Should have been called with sanitized query expect(prismaService.$queryRaw).toHaveBeenCalled(); }); it("should apply status filter when provided", async () => { prismaService.$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(0) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); await service.search("test", mockWorkspaceId, { status: EntryStatus.DRAFT, }); expect(prismaService.$queryRaw).toHaveBeenCalled(); }); it("should handle pagination correctly", async () => { prismaService.$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(50) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); const result = await service.search("test", mockWorkspaceId, { page: 2, limit: 10, }); expect(result.pagination.page).toBe(2); expect(result.pagination.limit).toBe(10); expect(result.pagination.total).toBe(50); expect(result.pagination.totalPages).toBe(5); }); it("should filter by tags when provided", async () => { const mockSearchResults = [ { id: "entry-1", workspace_id: mockWorkspaceId, slug: "tagged-entry", title: "Tagged Entry", content: "Content with search term", content_html: "

Content with search term

", summary: null, status: EntryStatus.PUBLISHED, visibility: "WORKSPACE", created_at: new Date(), updated_at: new Date(), created_by: "user-1", updated_by: "user-1", rank: 0.8, headline: "Content with search term", }, ]; prismaService.$queryRaw .mockResolvedValueOnce(mockSearchResults) .mockResolvedValueOnce([{ count: BigInt(1) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([ { entryId: "entry-1", tag: { id: "tag-1", name: "API", slug: "api", color: "#blue", }, }, ]); const result = await service.search("search term", mockWorkspaceId, { tags: ["api", "documentation"], }); expect(result.data).toHaveLength(1); expect(result.data[0].title).toBe("Tagged Entry"); expect(result.data[0].tags).toHaveLength(1); expect(prismaService.$queryRaw).toHaveBeenCalled(); }); it("should combine full-text search with tag filtering", async () => { prismaService.$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(0) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); await service.search("test query", mockWorkspaceId, { tags: ["api"], status: EntryStatus.PUBLISHED, page: 1, limit: 20, }); // Verify the query was called (the actual SQL logic will be tested in integration tests) expect(prismaService.$queryRaw).toHaveBeenCalled(); }); }); describe("searchByTags", () => { it("should return empty results for empty tags array", async () => { const result = await service.searchByTags([], mockWorkspaceId); expect(result.data).toEqual([]); expect(result.pagination.total).toBe(0); }); it("should find entries with all specified tags", async () => { const mockEntries = [ { id: "entry-1", workspaceId: mockWorkspaceId, slug: "tagged-entry", title: "Tagged Entry", content: "Content with tags", contentHtml: "

Content with tags

", summary: null, status: EntryStatus.PUBLISHED, visibility: "WORKSPACE", createdAt: new Date(), updatedAt: new Date(), createdBy: "user-1", updatedBy: "user-1", tags: [ { tag: { id: "tag-1", name: "API", slug: "api", color: "#blue", }, }, { tag: { id: "tag-2", name: "Documentation", slug: "documentation", color: "#green", }, }, ], }, ]; prismaService.knowledgeEntry.count.mockResolvedValue(1); prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries); const result = await service.searchByTags(["api", "documentation"], mockWorkspaceId); expect(result.data).toHaveLength(1); expect(result.data[0].title).toBe("Tagged Entry"); expect(result.data[0].tags).toHaveLength(2); expect(result.pagination.total).toBe(1); }); it("should apply status filter when provided", async () => { prismaService.knowledgeEntry.count.mockResolvedValue(0); prismaService.knowledgeEntry.findMany.mockResolvedValue([]); await service.searchByTags(["api"], mockWorkspaceId, { status: EntryStatus.DRAFT, }); expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: EntryStatus.DRAFT, }), }) ); }); it("should handle pagination correctly", async () => { prismaService.knowledgeEntry.count.mockResolvedValue(25); prismaService.knowledgeEntry.findMany.mockResolvedValue([]); const result = await service.searchByTags(["api"], mockWorkspaceId, { page: 2, limit: 10, }); expect(result.pagination.page).toBe(2); expect(result.pagination.limit).toBe(10); expect(result.pagination.total).toBe(25); expect(result.pagination.totalPages).toBe(3); }); }); describe("recentEntries", () => { it("should return recently modified entries", async () => { const mockEntries = [ { id: "entry-1", workspaceId: mockWorkspaceId, slug: "recent-entry", title: "Recent Entry", content: "Recently updated content", contentHtml: "

Recently updated content

", summary: null, status: EntryStatus.PUBLISHED, visibility: "WORKSPACE", createdAt: new Date(), updatedAt: new Date(), createdBy: "user-1", updatedBy: "user-1", tags: [], }, ]; prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries); const result = await service.recentEntries(mockWorkspaceId); expect(result).toHaveLength(1); expect(result[0].title).toBe("Recent Entry"); expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ orderBy: { updatedAt: "desc" }, take: 10, }) ); }); it("should respect the limit parameter", async () => { prismaService.knowledgeEntry.findMany.mockResolvedValue([]); await service.recentEntries(mockWorkspaceId, 5); expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ take: 5, }) ); }); it("should apply status filter when provided", async () => { prismaService.knowledgeEntry.findMany.mockResolvedValue([]); await service.recentEntries(mockWorkspaceId, 10, EntryStatus.DRAFT); expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: EntryStatus.DRAFT, }), }) ); }); it("should exclude archived entries by default", async () => { prismaService.knowledgeEntry.findMany.mockResolvedValue([]); await service.recentEntries(mockWorkspaceId); expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: { not: EntryStatus.ARCHIVED }, }), }) ); }); }); describe("semanticSearch", () => { it("should throw error when OllamaEmbeddingService is not configured", async () => { const ollamaService = service["ollama"]; ollamaService.isConfigured = vi.fn().mockResolvedValue(false); await expect(service.semanticSearch("test query", mockWorkspaceId)).rejects.toThrow( "Semantic search requires Ollama to be configured" ); }); it("should perform semantic search using vector similarity", async () => { const ollamaService = service["ollama"]; ollamaService.isConfigured = vi.fn().mockResolvedValue(true); // Mock embedding generation const mockEmbedding = new Array(1536).fill(0.1); ollamaService.generateEmbedding = vi.fn().mockResolvedValue(mockEmbedding); const mockSearchResults = [ { id: "entry-1", workspace_id: mockWorkspaceId, slug: "semantic-entry", title: "Semantic Entry", content: "This is semantically similar content", content_html: "

This is semantically similar content

", summary: null, status: EntryStatus.PUBLISHED, visibility: "WORKSPACE", created_at: new Date(), updated_at: new Date(), created_by: "user-1", updated_by: "user-1", rank: 0.85, headline: null, }, ]; prismaService.$queryRaw .mockResolvedValueOnce(mockSearchResults) .mockResolvedValueOnce([{ count: BigInt(1) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); const result = await service.semanticSearch("semantic query", mockWorkspaceId); expect(result.data).toHaveLength(1); expect(result.data[0].rank).toBe(0.85); expect(ollamaService.generateEmbedding).toHaveBeenCalledWith("semantic query", {}); expect(prismaService.$queryRaw).toHaveBeenCalled(); }); it("should apply similarity threshold filter", async () => { const ollamaService = service["ollama"]; ollamaService.isConfigured = vi.fn().mockResolvedValue(true); const mockEmbedding = new Array(1536).fill(0.1); ollamaService.generateEmbedding = vi.fn().mockResolvedValue(mockEmbedding); // Set environment variable for similarity threshold process.env.SEMANTIC_SEARCH_SIMILARITY_THRESHOLD = "0.7"; const mockSearchResults = [ { id: "entry-1", workspace_id: mockWorkspaceId, slug: "high-similarity", title: "High Similarity Entry", content: "Very similar content", content_html: "

Very similar content

", summary: null, status: EntryStatus.PUBLISHED, visibility: "WORKSPACE", created_at: new Date(), updated_at: new Date(), created_by: "user-1", updated_by: "user-1", rank: 0.9, headline: null, }, ]; prismaService.$queryRaw .mockResolvedValueOnce(mockSearchResults) .mockResolvedValueOnce([{ count: BigInt(1) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); const result = await service.semanticSearch("query", mockWorkspaceId); expect(result.data).toHaveLength(1); expect(result.data[0].rank).toBeGreaterThanOrEqual(0.7); // Clean up delete process.env.SEMANTIC_SEARCH_SIMILARITY_THRESHOLD; }); it("should handle pagination correctly", async () => { const ollamaService = service["ollama"]; ollamaService.isConfigured = vi.fn().mockResolvedValue(true); const mockEmbedding = new Array(1536).fill(0.1); ollamaService.generateEmbedding = vi.fn().mockResolvedValue(mockEmbedding); prismaService.$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(25) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); const result = await service.semanticSearch("query", mockWorkspaceId, { page: 2, limit: 10, }); expect(result.pagination.page).toBe(2); expect(result.pagination.limit).toBe(10); expect(result.pagination.total).toBe(25); expect(result.pagination.totalPages).toBe(3); }); it("should apply status filter when provided", async () => { const ollamaService = service["ollama"]; ollamaService.isConfigured = vi.fn().mockResolvedValue(true); const mockEmbedding = new Array(1536).fill(0.1); ollamaService.generateEmbedding = vi.fn().mockResolvedValue(mockEmbedding); prismaService.$queryRaw .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ count: BigInt(0) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); await service.semanticSearch("query", mockWorkspaceId, { status: EntryStatus.DRAFT, }); // Verify the query was called with status filter expect(prismaService.$queryRaw).toHaveBeenCalled(); }); it("should include similarity scores in results", async () => { const ollamaService = service["ollama"]; ollamaService.isConfigured = vi.fn().mockResolvedValue(true); const mockEmbedding = new Array(1536).fill(0.1); ollamaService.generateEmbedding = vi.fn().mockResolvedValue(mockEmbedding); const mockSearchResults = [ { id: "entry-1", workspace_id: mockWorkspaceId, slug: "entry-1", title: "Entry 1", content: "Content 1", content_html: "

Content 1

", summary: null, status: EntryStatus.PUBLISHED, visibility: "WORKSPACE", created_at: new Date(), updated_at: new Date(), created_by: "user-1", updated_by: "user-1", rank: 0.95, headline: null, }, { id: "entry-2", workspace_id: mockWorkspaceId, slug: "entry-2", title: "Entry 2", content: "Content 2", content_html: "

Content 2

", summary: null, status: EntryStatus.PUBLISHED, visibility: "WORKSPACE", created_at: new Date(), updated_at: new Date(), created_by: "user-1", updated_by: "user-1", rank: 0.75, headline: null, }, ]; prismaService.$queryRaw .mockResolvedValueOnce(mockSearchResults) .mockResolvedValueOnce([{ count: BigInt(2) }]); prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); const result = await service.semanticSearch("query", mockWorkspaceId); expect(result.data).toHaveLength(2); expect(result.data[0].rank).toBe(0.95); expect(result.data[1].rank).toBe(0.75); // Verify results are ordered by similarity (descending) expect(result.data[0].rank).toBeGreaterThan(result.data[1].rank); }); }); });