diff --git a/apps/api/src/knowledge/dto/index.ts b/apps/api/src/knowledge/dto/index.ts index 779082c..4e28afe 100644 --- a/apps/api/src/knowledge/dto/index.ts +++ b/apps/api/src/knowledge/dto/index.ts @@ -4,7 +4,14 @@ export { EntryQueryDto } from "./entry-query.dto"; export { CreateTagDto } from "./create-tag.dto"; export { UpdateTagDto } from "./update-tag.dto"; export { RestoreVersionDto } from "./restore-version.dto"; -export { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./search-query.dto"; +export { + SearchQueryDto, + TagSearchDto, + RecentEntriesDto, + SemanticSearchBodyDto, + SemanticSearchQueryDto, + HybridSearchBodyDto, +} from "./search-query.dto"; export { GraphQueryDto, GraphFilterDto } from "./graph-query.dto"; export { ExportQueryDto, ExportFormat } from "./import-export.dto"; export type { ImportResult, ImportResponseDto } from "./import-export.dto"; diff --git a/apps/api/src/knowledge/dto/search-query.dto.ts b/apps/api/src/knowledge/dto/search-query.dto.ts index c6ee938..d7428a7 100644 --- a/apps/api/src/knowledge/dto/search-query.dto.ts +++ b/apps/api/src/knowledge/dto/search-query.dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString, IsInt, Min, Max, IsArray, IsEnum } from "class-validator"; +import { IsOptional, IsString, IsInt, Min, Max, IsArray, IsEnum, MaxLength } from "class-validator"; import { Type, Transform } from "class-transformer"; import { EntryStatus } from "@prisma/client"; @@ -75,3 +75,49 @@ export class RecentEntriesDto { @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) status?: EntryStatus; } + +/** + * DTO for semantic search request body + * Validates the query string and optional status filter + */ +export class SemanticSearchBodyDto { + @IsString({ message: "query must be a string" }) + @MaxLength(500, { message: "query must not exceed 500 characters" }) + query!: string; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; +} + +/** + * DTO for semantic/hybrid search query parameters (pagination) + */ +export class SemanticSearchQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} + +/** + * DTO for hybrid search request body + * Validates the query string and optional status filter + */ +export class HybridSearchBodyDto { + @IsString({ message: "query must be a string" }) + @MaxLength(500, { message: "query must not exceed 500 characters" }) + query!: string; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; +} diff --git a/apps/api/src/knowledge/search.controller.spec.ts b/apps/api/src/knowledge/search.controller.spec.ts index d9e84ad..6175793 100644 --- a/apps/api/src/knowledge/search.controller.spec.ts +++ b/apps/api/src/knowledge/search.controller.spec.ts @@ -1,10 +1,13 @@ 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; @@ -15,6 +18,8 @@ describe("SearchController", () => { search: vi.fn(), searchByTags: vi.fn(), recentEntries: vi.fn(), + semanticSearch: vi.fn(), + hybridSearch: vi.fn(), }; beforeEach(async () => { @@ -217,4 +222,266 @@ describe("SearchController", () => { ); }); }); + + 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(); + }); }); diff --git a/apps/api/src/knowledge/search.controller.ts b/apps/api/src/knowledge/search.controller.ts index 43fee1c..fc7607f 100644 --- a/apps/api/src/knowledge/search.controller.ts +++ b/apps/api/src/knowledge/search.controller.ts @@ -1,10 +1,16 @@ import { Controller, Get, Post, Body, Query, UseGuards } from "@nestjs/common"; import { SearchService, PaginatedSearchResults } from "./services/search.service"; -import { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./dto"; +import { + SearchQueryDto, + TagSearchDto, + RecentEntriesDto, + SemanticSearchBodyDto, + SemanticSearchQueryDto, + HybridSearchBodyDto, +} from "./dto"; import { AuthGuard } from "../auth/guards/auth.guard"; import { WorkspaceGuard, PermissionGuard } from "../common/guards"; import { Workspace, Permission, RequirePermission } from "../common/decorators"; -import { EntryStatus } from "@prisma/client"; import type { PaginatedEntries, KnowledgeEntryWithTags } from "./entities/knowledge-entry.entity"; /** @@ -112,14 +118,13 @@ export class SearchController { @RequirePermission(Permission.WORKSPACE_ANY) async semanticSearch( @Workspace() workspaceId: string, - @Body() body: { query: string; status?: EntryStatus }, - @Query("page") page?: number, - @Query("limit") limit?: number + @Body() body: SemanticSearchBodyDto, + @Query() query: SemanticSearchQueryDto ): Promise { return this.searchService.semanticSearch(body.query, workspaceId, { status: body.status, - page, - limit, + page: query.page, + limit: query.limit, }); } @@ -138,14 +143,13 @@ export class SearchController { @RequirePermission(Permission.WORKSPACE_ANY) async hybridSearch( @Workspace() workspaceId: string, - @Body() body: { query: string; status?: EntryStatus }, - @Query("page") page?: number, - @Query("limit") limit?: number + @Body() body: HybridSearchBodyDto, + @Query() query: SemanticSearchQueryDto ): Promise { return this.searchService.hybridSearch(body.query, workspaceId, { status: body.status, - page, - limit, + page: query.page, + limit: query.limit, }); } }