Files
stack/apps/api/src/knowledge/search.controller.spec.ts
Jason Woltje bb6e08208c
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(SEC-API-21): Add DTO validation for semantic/hybrid search body
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>
2026-02-06 13:35:06 -06:00

488 lines
15 KiB
TypeScript

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>(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();
});
});