fix(SEC-API-21): Add DTO validation for semantic/hybrid search body
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace inline type annotations with proper class-validator DTOs for the semantic and hybrid search endpoints. Adds SemanticSearchBodyDto, HybridSearchBodyDto (query: @IsString @MaxLength(500), status: @IsOptional @IsEnum(EntryStatus)), and SemanticSearchQueryDto (page/limit with @IsInt @Min/@Max validation). Includes 22 new tests covering DTO validation edge cases and controller integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user