- 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
This commit is contained in:
352
apps/api/src/knowledge/knowledge.service.versions.spec.ts
Normal file
352
apps/api/src/knowledge/knowledge.service.versions.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user