feat(knowledge): add search service
This commit is contained in:
318
apps/api/src/knowledge/services/search.service.spec.ts
Normal file
318
apps/api/src/knowledge/services/search.service.spec.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
import { SearchService } from "./search.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
describe("SearchService", () => {
|
||||
let service: SearchService;
|
||||
let prismaService: any;
|
||||
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockQueryRaw = vi.fn();
|
||||
const mockKnowledgeEntryCount = vi.fn();
|
||||
const mockKnowledgeEntryFindMany = vi.fn();
|
||||
const mockKnowledgeEntryTagFindMany = vi.fn();
|
||||
|
||||
const mockPrismaService = {
|
||||
$queryRaw: mockQueryRaw,
|
||||
knowledgeEntry: {
|
||||
count: mockKnowledgeEntryCount,
|
||||
findMany: mockKnowledgeEntryFindMany,
|
||||
},
|
||||
knowledgeEntryTag: {
|
||||
findMany: mockKnowledgeEntryTagFindMany,
|
||||
},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SearchService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SearchService>(SearchService);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("should return empty results for empty query", async () => {
|
||||
const result = await service.search("", mockWorkspaceId);
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(result.pagination.total).toBe(0);
|
||||
expect(result.query).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty results for whitespace-only query", async () => {
|
||||
const result = await service.search(" ", mockWorkspaceId);
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(result.pagination.total).toBe(0);
|
||||
});
|
||||
|
||||
it("should perform full-text search and return ranked results", async () => {
|
||||
const mockSearchResults = [
|
||||
{
|
||||
id: "entry-1",
|
||||
workspace_id: mockWorkspaceId,
|
||||
slug: "test-entry",
|
||||
title: "Test Entry",
|
||||
content: "This is test content",
|
||||
content_html: "<p>This is test content</p>",
|
||||
summary: "Test summary",
|
||||
status: EntryStatus.PUBLISHED,
|
||||
visibility: "WORKSPACE",
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
created_by: "user-1",
|
||||
updated_by: "user-1",
|
||||
rank: 0.5,
|
||||
headline: "This is <mark>test</mark> content",
|
||||
},
|
||||
];
|
||||
|
||||
prismaService.$queryRaw
|
||||
.mockResolvedValueOnce(mockSearchResults)
|
||||
.mockResolvedValueOnce([{ count: BigInt(1) }]);
|
||||
|
||||
prismaService.knowledgeEntryTag.findMany.mockResolvedValue([
|
||||
{
|
||||
entryId: "entry-1",
|
||||
tag: {
|
||||
id: "tag-1",
|
||||
name: "Documentation",
|
||||
slug: "documentation",
|
||||
color: "#blue",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.search("test", mockWorkspaceId);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].title).toBe("Test Entry");
|
||||
expect(result.data[0].rank).toBe(0.5);
|
||||
expect(result.data[0].headline).toBe("This is <mark>test</mark> content");
|
||||
expect(result.data[0].tags).toHaveLength(1);
|
||||
expect(result.pagination.total).toBe(1);
|
||||
expect(result.query).toBe("test");
|
||||
});
|
||||
|
||||
it("should sanitize search query removing special characters", async () => {
|
||||
prismaService.$queryRaw
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ count: BigInt(0) }]);
|
||||
prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.search("test & query | !special:chars*", mockWorkspaceId);
|
||||
|
||||
// Should have been called with sanitized query
|
||||
expect(prismaService.$queryRaw).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should apply status filter when provided", async () => {
|
||||
prismaService.$queryRaw
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ count: BigInt(0) }]);
|
||||
prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.search("test", mockWorkspaceId, {
|
||||
status: EntryStatus.DRAFT,
|
||||
});
|
||||
|
||||
expect(prismaService.$queryRaw).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle pagination correctly", async () => {
|
||||
prismaService.$queryRaw
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ count: BigInt(50) }]);
|
||||
prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.search("test", mockWorkspaceId, {
|
||||
page: 2,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.pagination.page).toBe(2);
|
||||
expect(result.pagination.limit).toBe(10);
|
||||
expect(result.pagination.total).toBe(50);
|
||||
expect(result.pagination.totalPages).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchByTags", () => {
|
||||
it("should return empty results for empty tags array", async () => {
|
||||
const result = await service.searchByTags([], mockWorkspaceId);
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(result.pagination.total).toBe(0);
|
||||
});
|
||||
|
||||
it("should find entries with all specified tags", async () => {
|
||||
const mockEntries = [
|
||||
{
|
||||
id: "entry-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
slug: "tagged-entry",
|
||||
title: "Tagged Entry",
|
||||
content: "Content with tags",
|
||||
contentHtml: "<p>Content with tags</p>",
|
||||
summary: null,
|
||||
status: EntryStatus.PUBLISHED,
|
||||
visibility: "WORKSPACE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
tags: [
|
||||
{
|
||||
tag: {
|
||||
id: "tag-1",
|
||||
name: "API",
|
||||
slug: "api",
|
||||
color: "#blue",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: {
|
||||
id: "tag-2",
|
||||
name: "Documentation",
|
||||
slug: "documentation",
|
||||
color: "#green",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
prismaService.knowledgeEntry.count.mockResolvedValue(1);
|
||||
prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries);
|
||||
|
||||
const result = await service.searchByTags(
|
||||
["api", "documentation"],
|
||||
mockWorkspaceId
|
||||
);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].title).toBe("Tagged Entry");
|
||||
expect(result.data[0].tags).toHaveLength(2);
|
||||
expect(result.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
it("should apply status filter when provided", async () => {
|
||||
prismaService.knowledgeEntry.count.mockResolvedValue(0);
|
||||
prismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.searchByTags(["api"], mockWorkspaceId, {
|
||||
status: EntryStatus.DRAFT,
|
||||
});
|
||||
|
||||
expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: EntryStatus.DRAFT,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle pagination correctly", async () => {
|
||||
prismaService.knowledgeEntry.count.mockResolvedValue(25);
|
||||
prismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.searchByTags(["api"], mockWorkspaceId, {
|
||||
page: 2,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.pagination.page).toBe(2);
|
||||
expect(result.pagination.limit).toBe(10);
|
||||
expect(result.pagination.total).toBe(25);
|
||||
expect(result.pagination.totalPages).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recentEntries", () => {
|
||||
it("should return recently modified entries", async () => {
|
||||
const mockEntries = [
|
||||
{
|
||||
id: "entry-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
slug: "recent-entry",
|
||||
title: "Recent Entry",
|
||||
content: "Recently updated content",
|
||||
contentHtml: "<p>Recently updated content</p>",
|
||||
summary: null,
|
||||
status: EntryStatus.PUBLISHED,
|
||||
visibility: "WORKSPACE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries);
|
||||
|
||||
const result = await service.recentEntries(mockWorkspaceId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe("Recent Entry");
|
||||
expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 10,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should respect the limit parameter", async () => {
|
||||
prismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.recentEntries(mockWorkspaceId, 5);
|
||||
|
||||
expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply status filter when provided", async () => {
|
||||
prismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.recentEntries(mockWorkspaceId, 10, EntryStatus.DRAFT);
|
||||
|
||||
expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: EntryStatus.DRAFT,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should exclude archived entries by default", async () => {
|
||||
prismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.recentEntries(mockWorkspaceId);
|
||||
|
||||
expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { not: EntryStatus.ARCHIVED },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user