Files
stack/apps/api/src/knowledge/knowledge.service.versions.spec.ts
Jason Woltje 7465d0a3c2 feat: add knowledge version history (closes #75, closes #76)
- 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
2026-01-29 23:27:03 -06:00

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
);
});
});
});