Updated semantic search to use OllamaEmbeddingService instead of OpenAI: - Replaced EmbeddingService with OllamaEmbeddingService in SearchService - Added configurable similarity threshold (SEMANTIC_SEARCH_SIMILARITY_THRESHOLD) - Updated both semanticSearch() and hybridSearch() methods - Added comprehensive tests for semantic search functionality - Updated controller documentation to reflect Ollama requirement - All tests passing with 85%+ coverage Related changes: - Updated knowledge.service.versions.spec.ts to include OllamaEmbeddingService - Added similarity threshold environment variable to .env.example Fixes #70 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { KnowledgeService } from "./knowledge.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { LinkSyncService } from "./services/link-sync.service";
|
|
import { KnowledgeCacheService } from "./services/cache.service";
|
|
import { EmbeddingService } from "./services/embedding.service";
|
|
import { OllamaEmbeddingService } from "./services/ollama-embedding.service";
|
|
import { EmbeddingQueueService } from "./queues/embedding-queue.service";
|
|
import { NotFoundException } from "@nestjs/common";
|
|
|
|
describe("KnowledgeService - Version History", () => {
|
|
let service: KnowledgeService;
|
|
let prisma: PrismaService;
|
|
let linkSync: LinkSyncService;
|
|
|
|
const workspaceId = "workspace-123";
|
|
const userId = "user-456";
|
|
const entryId = "entry-789";
|
|
const slug = "test-entry";
|
|
|
|
const mockEntry = {
|
|
id: entryId,
|
|
workspaceId,
|
|
slug,
|
|
title: "Test Entry",
|
|
content: "# Test Content",
|
|
contentHtml: "<h1>Test Content</h1>",
|
|
summary: "Test summary",
|
|
status: "PUBLISHED",
|
|
visibility: "WORKSPACE",
|
|
createdAt: new Date("2026-01-01"),
|
|
updatedAt: new Date("2026-01-20"),
|
|
createdBy: userId,
|
|
updatedBy: userId,
|
|
};
|
|
|
|
const mockVersions = [
|
|
{
|
|
id: "version-3",
|
|
entryId,
|
|
version: 3,
|
|
title: "Test Entry v3",
|
|
content: "# Version 3",
|
|
summary: "Summary v3",
|
|
createdAt: new Date("2026-01-20"),
|
|
createdBy: userId,
|
|
changeNote: "Updated content",
|
|
author: {
|
|
id: userId,
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
},
|
|
},
|
|
{
|
|
id: "version-2",
|
|
entryId,
|
|
version: 2,
|
|
title: "Test Entry v2",
|
|
content: "# Version 2",
|
|
summary: "Summary v2",
|
|
createdAt: new Date("2026-01-15"),
|
|
createdBy: userId,
|
|
changeNote: "Second version",
|
|
author: {
|
|
id: userId,
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
},
|
|
},
|
|
{
|
|
id: "version-1",
|
|
entryId,
|
|
version: 1,
|
|
title: "Test Entry v1",
|
|
content: "# Version 1",
|
|
summary: "Summary v1",
|
|
createdAt: new Date("2026-01-10"),
|
|
createdBy: userId,
|
|
changeNote: "Initial version",
|
|
author: {
|
|
id: userId,
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
},
|
|
},
|
|
];
|
|
|
|
const mockPrismaService = {
|
|
knowledgeEntry: {
|
|
findUnique: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
knowledgeEntryVersion: {
|
|
count: vi.fn(),
|
|
findMany: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
$transaction: vi.fn(),
|
|
};
|
|
|
|
const mockLinkSyncService = {
|
|
syncLinks: vi.fn(),
|
|
};
|
|
|
|
const mockCacheService = {
|
|
getEntry: vi.fn().mockResolvedValue(null),
|
|
setEntry: vi.fn().mockResolvedValue(undefined),
|
|
invalidateEntry: vi.fn().mockResolvedValue(undefined),
|
|
getSearch: vi.fn().mockResolvedValue(null),
|
|
setSearch: vi.fn().mockResolvedValue(undefined),
|
|
invalidateSearches: vi.fn().mockResolvedValue(undefined),
|
|
getGraph: vi.fn().mockResolvedValue(null),
|
|
setGraph: vi.fn().mockResolvedValue(undefined),
|
|
invalidateGraphs: vi.fn().mockResolvedValue(undefined),
|
|
invalidateGraphsForEntry: vi.fn().mockResolvedValue(undefined),
|
|
clearWorkspaceCache: vi.fn().mockResolvedValue(undefined),
|
|
getStats: vi.fn().mockReturnValue({ hits: 0, misses: 0, sets: 0, deletes: 0, hitRate: 0 }),
|
|
resetStats: vi.fn(),
|
|
isEnabled: vi.fn().mockReturnValue(false),
|
|
};
|
|
|
|
const mockEmbeddingService = {
|
|
isConfigured: vi.fn().mockReturnValue(false),
|
|
generateEmbedding: vi.fn().mockResolvedValue(null),
|
|
batchGenerateEmbeddings: vi.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
const mockOllamaEmbeddingService = {
|
|
isConfigured: vi.fn().mockResolvedValue(false),
|
|
generateEmbedding: vi.fn().mockResolvedValue([]),
|
|
generateAndStoreEmbedding: vi.fn().mockResolvedValue(undefined),
|
|
batchGenerateEmbeddings: vi.fn().mockResolvedValue(0),
|
|
};
|
|
|
|
const mockEmbeddingQueueService = {
|
|
enqueueEmbeddingGeneration: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
KnowledgeService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrismaService,
|
|
},
|
|
{
|
|
provide: LinkSyncService,
|
|
useValue: mockLinkSyncService,
|
|
},
|
|
{
|
|
provide: KnowledgeCacheService,
|
|
useValue: mockCacheService,
|
|
},
|
|
{
|
|
provide: EmbeddingService,
|
|
useValue: mockEmbeddingService,
|
|
},
|
|
{
|
|
provide: OllamaEmbeddingService,
|
|
useValue: mockOllamaEmbeddingService,
|
|
},
|
|
{
|
|
provide: EmbeddingQueueService,
|
|
useValue: mockEmbeddingQueueService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<KnowledgeService>(KnowledgeService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
linkSync = module.get<LinkSyncService>(LinkSyncService);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("findVersions", () => {
|
|
it("should return paginated versions for an entry", async () => {
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
|
|
mockPrismaService.knowledgeEntryVersion.count.mockResolvedValue(3);
|
|
mockPrismaService.knowledgeEntryVersion.findMany.mockResolvedValue(mockVersions);
|
|
|
|
const result = await service.findVersions(workspaceId, slug, 1, 20);
|
|
|
|
expect(result).toEqual({
|
|
data: mockVersions,
|
|
pagination: {
|
|
page: 1,
|
|
limit: 20,
|
|
total: 3,
|
|
totalPages: 1,
|
|
},
|
|
});
|
|
|
|
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(mockPrismaService.knowledgeEntryVersion.count).toHaveBeenCalledWith({
|
|
where: { entryId },
|
|
});
|
|
|
|
expect(mockPrismaService.knowledgeEntryVersion.findMany).toHaveBeenCalledWith({
|
|
where: { entryId },
|
|
include: {
|
|
author: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
version: "desc",
|
|
},
|
|
skip: 0,
|
|
take: 20,
|
|
});
|
|
});
|
|
|
|
it("should handle pagination correctly", async () => {
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
|
|
mockPrismaService.knowledgeEntryVersion.count.mockResolvedValue(50);
|
|
mockPrismaService.knowledgeEntryVersion.findMany.mockResolvedValue([mockVersions[0]]);
|
|
|
|
const result = await service.findVersions(workspaceId, slug, 2, 20);
|
|
|
|
expect(result.pagination).toEqual({
|
|
page: 2,
|
|
limit: 20,
|
|
total: 50,
|
|
totalPages: 3,
|
|
});
|
|
|
|
expect(mockPrismaService.knowledgeEntryVersion.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
skip: 20, // (page 2 - 1) * 20
|
|
take: 20,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should throw NotFoundException if entry does not exist", async () => {
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.findVersions(workspaceId, slug)).rejects.toThrow(NotFoundException);
|
|
|
|
expect(mockPrismaService.knowledgeEntryVersion.count).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("findVersion", () => {
|
|
it("should return a specific version", async () => {
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
|
|
mockPrismaService.knowledgeEntryVersion.findUnique.mockResolvedValue(mockVersions[1]);
|
|
|
|
const result = await service.findVersion(workspaceId, slug, 2);
|
|
|
|
expect(result).toEqual(mockVersions[1]);
|
|
|
|
expect(mockPrismaService.knowledgeEntryVersion.findUnique).toHaveBeenCalledWith({
|
|
where: {
|
|
entryId_version: {
|
|
entryId,
|
|
version: 2,
|
|
},
|
|
},
|
|
include: {
|
|
author: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should throw NotFoundException if entry does not exist", async () => {
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.findVersion(workspaceId, slug, 2)).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it("should throw NotFoundException if version does not exist", async () => {
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
|
|
mockPrismaService.knowledgeEntryVersion.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.findVersion(workspaceId, slug, 99)).rejects.toThrow(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe("restoreVersion", () => {
|
|
it("should restore a previous version and create a new version", async () => {
|
|
const entryWithVersions = {
|
|
...mockEntry,
|
|
versions: [mockVersions[0]], // Latest version is v3
|
|
tags: [],
|
|
};
|
|
|
|
const updatedEntry = {
|
|
...mockEntry,
|
|
title: "Test Entry v2",
|
|
content: "# Version 2",
|
|
contentHtml: "<h1>Version 2</h1>",
|
|
summary: "Summary v2",
|
|
tags: [],
|
|
};
|
|
|
|
// Mock findVersion to return version 2
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntry);
|
|
mockPrismaService.knowledgeEntryVersion.findUnique.mockResolvedValue(mockVersions[1]);
|
|
|
|
// Mock transaction
|
|
mockPrismaService.$transaction.mockImplementation(async (callback) => {
|
|
const tx = {
|
|
knowledgeEntry: {
|
|
update: vi.fn().mockResolvedValue(updatedEntry),
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
...updatedEntry,
|
|
tags: [],
|
|
}),
|
|
},
|
|
knowledgeEntryVersion: {
|
|
create: vi.fn().mockResolvedValue({
|
|
id: "version-4",
|
|
entryId,
|
|
version: 4,
|
|
title: "Test Entry v2",
|
|
content: "# Version 2",
|
|
summary: "Summary v2",
|
|
createdAt: new Date(),
|
|
createdBy: userId,
|
|
changeNote: "Restored from version 2",
|
|
}),
|
|
},
|
|
};
|
|
return callback(tx);
|
|
});
|
|
|
|
// Mock for findVersion call
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(entryWithVersions);
|
|
|
|
const result = await service.restoreVersion(
|
|
workspaceId,
|
|
slug,
|
|
2,
|
|
userId,
|
|
"Custom restore note"
|
|
);
|
|
|
|
expect(result.title).toBe("Test Entry v2");
|
|
expect(result.content).toBe("# Version 2");
|
|
|
|
expect(mockLinkSyncService.syncLinks).toHaveBeenCalledWith(
|
|
workspaceId,
|
|
entryId,
|
|
"# Version 2"
|
|
);
|
|
});
|
|
|
|
it("should use default change note if not provided", async () => {
|
|
const entryWithVersions = {
|
|
...mockEntry,
|
|
versions: [mockVersions[0]],
|
|
tags: [],
|
|
};
|
|
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntry);
|
|
mockPrismaService.knowledgeEntryVersion.findUnique.mockResolvedValue(mockVersions[1]);
|
|
|
|
mockPrismaService.$transaction.mockImplementation(async (callback) => {
|
|
const createMock = vi.fn();
|
|
const tx = {
|
|
knowledgeEntry: {
|
|
update: vi.fn().mockResolvedValue(mockEntry),
|
|
findUnique: vi.fn().mockResolvedValue({ ...mockEntry, tags: [] }),
|
|
},
|
|
knowledgeEntryVersion: {
|
|
create: createMock,
|
|
},
|
|
};
|
|
await callback(tx);
|
|
return { ...mockEntry, tags: [] };
|
|
});
|
|
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(entryWithVersions);
|
|
|
|
await service.restoreVersion(workspaceId, slug, 2, userId);
|
|
|
|
// Verify transaction was called
|
|
expect(mockPrismaService.$transaction).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should throw NotFoundException if entry does not exist", async () => {
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.restoreVersion(workspaceId, slug, 2, userId)).rejects.toThrow(
|
|
NotFoundException
|
|
);
|
|
});
|
|
});
|
|
});
|