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>
488 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|