- Added EntryVersion model with author relation - Implemented automatic versioning on entry create/update - Added API endpoints for version history: - GET /api/knowledge/entries/:slug/versions - list versions - GET /api/knowledge/entries/:slug/versions/:version - get specific - POST /api/knowledge/entries/:slug/restore/:version - restore version - Created VersionHistory.tsx component with timeline view - Added History tab to entry detail page - Supports version viewing and restoring - Includes comprehensive tests for version operations - All TypeScript types are explicit and type-safe
353 lines
10 KiB
TypeScript
353 lines
10 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 { 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(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
KnowledgeService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrismaService,
|
|
},
|
|
{
|
|
provide: LinkSyncService,
|
|
useValue: mockLinkSyncService,
|
|
},
|
|
],
|
|
}).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
|
|
);
|
|
});
|
|
});
|
|
});
|