Add support for filtering search results by tags in the main search endpoint. Changes: - Add tags parameter to SearchQueryDto (comma-separated tag slugs) - Implement tag filtering in SearchService.search() method - Update SQL query to join with knowledge_entry_tags when tags provided - Entries must have ALL specified tags (AND logic) - Add tests for tag filtering (2 controller tests, 2 service tests) - Update endpoint documentation - Fix non-null assertion linting error The search endpoint now supports: - Full-text search with ranking (ts_rank) - Snippet generation with highlighting (ts_headline) - Status filtering - Tag filtering (new) - Pagination Example: GET /api/knowledge/search?q=api&tags=documentation,tutorial All tests pass (25 total), type checking passes, linting passes. Fixes #66 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
221 lines
6.2 KiB
TypeScript
221 lines
6.2 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { EntryStatus } from "@prisma/client";
|
|
import { SearchController } from "./search.controller";
|
|
import { SearchService } from "./services/search.service";
|
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|
|
|
describe("SearchController", () => {
|
|
let controller: SearchController;
|
|
|
|
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440000";
|
|
|
|
const mockSearchService = {
|
|
search: vi.fn(),
|
|
searchByTags: vi.fn(),
|
|
recentEntries: 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
|
|
);
|
|
});
|
|
});
|
|
});
|