import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { EntryStatus } from "@prisma/client"; import { validate } from "class-validator"; import { plainToInstance } from "class-transformer"; import { SearchController } from "./search.controller"; import { SearchService } from "./services/search.service"; import { AuthGuard } from "../auth/guards/auth.guard"; import { WorkspaceGuard, PermissionGuard } from "../common/guards"; import { SemanticSearchBodyDto, SemanticSearchQueryDto, HybridSearchBodyDto } from "./dto"; describe("SearchController", () => { let controller: SearchController; const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440000"; const mockSearchService = { search: vi.fn(), searchByTags: vi.fn(), recentEntries: vi.fn(), semanticSearch: vi.fn(), hybridSearch: vi.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SearchController], providers: [ { provide: SearchService, useValue: mockSearchService, }, ], }) .overrideGuard(AuthGuard) .useValue({ canActivate: () => true }) .overrideGuard(WorkspaceGuard) .useValue({ canActivate: () => true }) .overrideGuard(PermissionGuard) .useValue({ canActivate: () => true }) .compile(); controller = module.get(SearchController); vi.clearAllMocks(); }); describe("search", () => { it("should call searchService.search with correct parameters", async () => { const mockResult = { data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, query: "test", }; mockSearchService.search.mockResolvedValue(mockResult); const result = await controller.search(mockWorkspaceId, { q: "test", page: 1, limit: 20, }); expect(mockSearchService.search).toHaveBeenCalledWith("test", mockWorkspaceId, { status: undefined, page: 1, limit: 20, }); expect(result).toEqual(mockResult); }); it("should pass status filter to service", async () => { mockSearchService.search.mockResolvedValue({ data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, query: "test", }); await controller.search(mockWorkspaceId, { q: "test", status: EntryStatus.PUBLISHED, }); expect(mockSearchService.search).toHaveBeenCalledWith("test", mockWorkspaceId, { status: EntryStatus.PUBLISHED, page: undefined, limit: undefined, }); }); it("should pass tags filter to service", async () => { mockSearchService.search.mockResolvedValue({ data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, query: "test", }); await controller.search(mockWorkspaceId, { q: "test", tags: ["api", "documentation"], }); expect(mockSearchService.search).toHaveBeenCalledWith("test", mockWorkspaceId, { status: undefined, page: undefined, limit: undefined, tags: ["api", "documentation"], }); }); it("should pass both status and tags filters to service", async () => { mockSearchService.search.mockResolvedValue({ data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, query: "test", }); await controller.search(mockWorkspaceId, { q: "test", status: EntryStatus.PUBLISHED, tags: ["api"], page: 2, limit: 10, }); expect(mockSearchService.search).toHaveBeenCalledWith("test", mockWorkspaceId, { status: EntryStatus.PUBLISHED, page: 2, limit: 10, tags: ["api"], }); }); }); describe("searchByTags", () => { it("should call searchService.searchByTags with correct parameters", async () => { const mockResult = { data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, }; mockSearchService.searchByTags.mockResolvedValue(mockResult); const result = await controller.searchByTags(mockWorkspaceId, { tags: ["api", "documentation"], page: 1, limit: 20, }); expect(mockSearchService.searchByTags).toHaveBeenCalledWith( ["api", "documentation"], mockWorkspaceId, { status: undefined, page: 1, limit: 20, } ); expect(result).toEqual(mockResult); }); it("should pass status filter to service", async () => { mockSearchService.searchByTags.mockResolvedValue({ data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, }); await controller.searchByTags(mockWorkspaceId, { tags: ["api"], status: EntryStatus.DRAFT, }); expect(mockSearchService.searchByTags).toHaveBeenCalledWith(["api"], mockWorkspaceId, { status: EntryStatus.DRAFT, page: undefined, limit: undefined, }); }); }); describe("recentEntries", () => { it("should call searchService.recentEntries with correct parameters", async () => { const mockEntries = [ { id: "entry-1", title: "Recent Entry", slug: "recent-entry", tags: [], }, ]; mockSearchService.recentEntries.mockResolvedValue(mockEntries); const result = await controller.recentEntries(mockWorkspaceId, { limit: 10, }); expect(mockSearchService.recentEntries).toHaveBeenCalledWith(mockWorkspaceId, 10, undefined); expect(result).toEqual({ data: mockEntries, count: 1, }); }); it("should use default limit of 10", async () => { mockSearchService.recentEntries.mockResolvedValue([]); await controller.recentEntries(mockWorkspaceId, {}); expect(mockSearchService.recentEntries).toHaveBeenCalledWith(mockWorkspaceId, 10, undefined); }); it("should pass status filter to service", async () => { mockSearchService.recentEntries.mockResolvedValue([]); await controller.recentEntries(mockWorkspaceId, { status: EntryStatus.PUBLISHED, limit: 5, }); expect(mockSearchService.recentEntries).toHaveBeenCalledWith( mockWorkspaceId, 5, EntryStatus.PUBLISHED ); }); }); describe("semanticSearch", () => { it("should call searchService.semanticSearch with correct parameters", async () => { const mockResult = { data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, query: "machine learning", }; mockSearchService.semanticSearch.mockResolvedValue(mockResult); const body = plainToInstance(SemanticSearchBodyDto, { query: "machine learning", }); const query = plainToInstance(SemanticSearchQueryDto, { page: 1, limit: 20, }); const result = await controller.semanticSearch(mockWorkspaceId, body, query); expect(mockSearchService.semanticSearch).toHaveBeenCalledWith( "machine learning", mockWorkspaceId, { status: undefined, page: 1, limit: 20, } ); expect(result).toEqual(mockResult); }); it("should pass status filter from body to service", async () => { mockSearchService.semanticSearch.mockResolvedValue({ data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, query: "test", }); const body = plainToInstance(SemanticSearchBodyDto, { query: "test", status: EntryStatus.PUBLISHED, }); const query = plainToInstance(SemanticSearchQueryDto, {}); await controller.semanticSearch(mockWorkspaceId, body, query); expect(mockSearchService.semanticSearch).toHaveBeenCalledWith("test", mockWorkspaceId, { status: EntryStatus.PUBLISHED, page: undefined, limit: undefined, }); }); }); describe("hybridSearch", () => { it("should call searchService.hybridSearch with correct parameters", async () => { const mockResult = { data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, query: "deep learning", }; mockSearchService.hybridSearch.mockResolvedValue(mockResult); const body = plainToInstance(HybridSearchBodyDto, { query: "deep learning", }); const query = plainToInstance(SemanticSearchQueryDto, { page: 2, limit: 10, }); const result = await controller.hybridSearch(mockWorkspaceId, body, query); expect(mockSearchService.hybridSearch).toHaveBeenCalledWith( "deep learning", mockWorkspaceId, { status: undefined, page: 2, limit: 10, } ); expect(result).toEqual(mockResult); }); it("should pass status filter from body to service", async () => { mockSearchService.hybridSearch.mockResolvedValue({ data: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, query: "test", }); const body = plainToInstance(HybridSearchBodyDto, { query: "test", status: EntryStatus.DRAFT, }); const query = plainToInstance(SemanticSearchQueryDto, {}); await controller.hybridSearch(mockWorkspaceId, body, query); expect(mockSearchService.hybridSearch).toHaveBeenCalledWith("test", mockWorkspaceId, { status: EntryStatus.DRAFT, page: undefined, limit: undefined, }); }); }); }); describe("SemanticSearchBodyDto validation", () => { it("should pass with valid query", async () => { const dto = plainToInstance(SemanticSearchBodyDto, { query: "test search" }); const errors = await validate(dto); expect(errors).toHaveLength(0); }); it("should pass with query and valid status", async () => { const dto = plainToInstance(SemanticSearchBodyDto, { query: "test search", status: EntryStatus.PUBLISHED, }); const errors = await validate(dto); expect(errors).toHaveLength(0); }); it("should fail when query is missing", async () => { const dto = plainToInstance(SemanticSearchBodyDto, {}); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const queryError = errors.find((e) => e.property === "query"); expect(queryError).toBeDefined(); }); it("should fail when query is not a string", async () => { const dto = plainToInstance(SemanticSearchBodyDto, { query: 12345 }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const queryError = errors.find((e) => e.property === "query"); expect(queryError).toBeDefined(); }); it("should fail when query exceeds 500 characters", async () => { const dto = plainToInstance(SemanticSearchBodyDto, { query: "a".repeat(501), }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const queryError = errors.find((e) => e.property === "query"); expect(queryError).toBeDefined(); }); it("should pass when query is exactly 500 characters", async () => { const dto = plainToInstance(SemanticSearchBodyDto, { query: "a".repeat(500), }); const errors = await validate(dto); expect(errors).toHaveLength(0); }); it("should fail with invalid status value", async () => { const dto = plainToInstance(SemanticSearchBodyDto, { query: "test", status: "INVALID_STATUS", }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const statusError = errors.find((e) => e.property === "status"); expect(statusError).toBeDefined(); }); }); describe("HybridSearchBodyDto validation", () => { it("should pass with valid query", async () => { const dto = plainToInstance(HybridSearchBodyDto, { query: "test search" }); const errors = await validate(dto); expect(errors).toHaveLength(0); }); it("should pass with query and valid status", async () => { const dto = plainToInstance(HybridSearchBodyDto, { query: "hybrid search", status: EntryStatus.DRAFT, }); const errors = await validate(dto); expect(errors).toHaveLength(0); }); it("should fail when query is missing", async () => { const dto = plainToInstance(HybridSearchBodyDto, {}); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const queryError = errors.find((e) => e.property === "query"); expect(queryError).toBeDefined(); }); it("should fail when query exceeds 500 characters", async () => { const dto = plainToInstance(HybridSearchBodyDto, { query: "a".repeat(501), }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const queryError = errors.find((e) => e.property === "query"); expect(queryError).toBeDefined(); }); it("should fail with invalid status value", async () => { const dto = plainToInstance(HybridSearchBodyDto, { query: "test", status: "NOT_A_STATUS", }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const statusError = errors.find((e) => e.property === "status"); expect(statusError).toBeDefined(); }); }); describe("SemanticSearchQueryDto validation", () => { it("should pass with valid page and limit", async () => { const dto = plainToInstance(SemanticSearchQueryDto, { page: 1, limit: 20 }); const errors = await validate(dto); expect(errors).toHaveLength(0); }); it("should pass with no parameters (all optional)", async () => { const dto = plainToInstance(SemanticSearchQueryDto, {}); const errors = await validate(dto); expect(errors).toHaveLength(0); }); it("should fail when page is less than 1", async () => { const dto = plainToInstance(SemanticSearchQueryDto, { page: 0 }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const pageError = errors.find((e) => e.property === "page"); expect(pageError).toBeDefined(); }); it("should fail when limit exceeds 100", async () => { const dto = plainToInstance(SemanticSearchQueryDto, { limit: 101 }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const limitError = errors.find((e) => e.property === "limit"); expect(limitError).toBeDefined(); }); it("should fail when limit is less than 1", async () => { const dto = plainToInstance(SemanticSearchQueryDto, { limit: 0 }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const limitError = errors.find((e) => e.property === "limit"); expect(limitError).toBeDefined(); }); it("should fail when page is not an integer", async () => { const dto = plainToInstance(SemanticSearchQueryDto, { page: 1.5 }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); const pageError = errors.find((e) => e.property === "page"); expect(pageError).toBeDefined(); }); });