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 { EmbeddingService } from "./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 mockEmbeddingService = { isConfigured: vi.fn().mockReturnValue(false), generateEmbedding: vi.fn().mockResolvedValue(null), batchGenerateEmbeddings: vi.fn().mockResolvedValue([]), }; const module: TestingModule = await Test.createTestingModule({ providers: [ SearchService, { provide: PrismaService, useValue: mockPrismaService, }, { provide: KnowledgeCacheService, useValue: mockCacheService, }, { provide: EmbeddingService, useValue: mockEmbeddingService, }, ], }).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); }); }); 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 }, }), }) ); }); }); });