@@ -3,6 +3,7 @@ export { UpdateEntryDto } from "./update-entry.dto";
|
||||
export { EntryQueryDto } from "./entry-query.dto";
|
||||
export { CreateTagDto } from "./create-tag.dto";
|
||||
export { UpdateTagDto } from "./update-tag.dto";
|
||||
export { RestoreVersionDto } from "./restore-version.dto";
|
||||
export {
|
||||
SearchQueryDto,
|
||||
TagSearchDto,
|
||||
|
||||
15
apps/api/src/knowledge/dto/restore-version.dto.ts
Normal file
15
apps/api/src/knowledge/dto/restore-version.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for restoring a previous version of a knowledge entry
|
||||
*/
|
||||
export class RestoreVersionDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: "changeNote must be a string" })
|
||||
@MaxLength(500, { message: "changeNote must not exceed 500 characters" })
|
||||
changeNote?: string;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Knowledge Entry Version entity
|
||||
* Represents a historical version of a knowledge entry
|
||||
*/
|
||||
export interface KnowledgeEntryVersionEntity {
|
||||
id: string;
|
||||
entryId: string;
|
||||
version: number;
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string | null;
|
||||
createdAt: Date;
|
||||
createdBy: string;
|
||||
changeNote: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version list item with author information
|
||||
*/
|
||||
export interface KnowledgeEntryVersionWithAuthor extends KnowledgeEntryVersionEntity {
|
||||
author: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated version list response
|
||||
*/
|
||||
export interface PaginatedVersions {
|
||||
data: KnowledgeEntryVersionWithAuthor[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
@@ -8,14 +8,16 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
} from "@nestjs/common";
|
||||
import { KnowledgeService } from "./knowledge.service";
|
||||
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto, GraphQueryDto } from "./dto";
|
||||
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto, RestoreVersionDto } from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import { LinkSyncService, GraphService } from "./services";
|
||||
import { LinkSyncService } from "./services/link-sync.service";
|
||||
|
||||
/**
|
||||
* Controller for knowledge entry endpoints
|
||||
@@ -27,8 +29,7 @@ import { LinkSyncService, GraphService } from "./services";
|
||||
export class KnowledgeController {
|
||||
constructor(
|
||||
private readonly knowledgeService: KnowledgeService,
|
||||
private readonly linkSync: LinkSyncService,
|
||||
private readonly graphService: GraphService
|
||||
private readonly linkSync: LinkSyncService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -135,29 +136,56 @@ export class KnowledgeController {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries/:slug/graph
|
||||
* Get entry-centered graph view
|
||||
* Returns the entry and connected nodes with specified depth
|
||||
* GET /api/knowledge/entries/:slug/versions
|
||||
* List all versions for an entry with pagination
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":slug/graph")
|
||||
@Get(":slug/versions")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getEntryGraph(
|
||||
async getVersions(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: GraphQueryDto
|
||||
@Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number
|
||||
) {
|
||||
// Find the entry to get its ID
|
||||
const entry = await this.knowledgeService.findOne(workspaceId, slug);
|
||||
|
||||
// Get graph
|
||||
const graph = await this.graphService.getEntryGraph(
|
||||
workspaceId,
|
||||
entry.id,
|
||||
query.depth || 1
|
||||
);
|
||||
|
||||
return graph;
|
||||
return this.knowledgeService.findVersions(workspaceId, slug, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries/:slug/versions/:version
|
||||
* Get a specific version of an entry
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":slug/versions/:version")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getVersion(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string,
|
||||
@Param("version", ParseIntPipe) version: number
|
||||
) {
|
||||
return this.knowledgeService.findVersion(workspaceId, slug, version);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/knowledge/entries/:slug/restore/:version
|
||||
* Restore a previous version of an entry
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Post(":slug/restore/:version")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async restoreVersion(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string,
|
||||
@Param("version", ParseIntPipe) version: number,
|
||||
@CurrentUser() user: any,
|
||||
@Body() restoreDto: RestoreVersionDto
|
||||
) {
|
||||
return this.knowledgeService.restoreVersion(
|
||||
workspaceId,
|
||||
slug,
|
||||
version,
|
||||
user.id,
|
||||
restoreDto.changeNote
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
KnowledgeEntryWithTags,
|
||||
PaginatedEntries,
|
||||
} from "./entities/knowledge-entry.entity";
|
||||
import type {
|
||||
KnowledgeEntryVersionWithAuthor,
|
||||
PaginatedVersions,
|
||||
} from "./entities/knowledge-entry-version.entity";
|
||||
import { renderMarkdown } from "./utils/markdown";
|
||||
import { LinkSyncService } from "./services/link-sync.service";
|
||||
|
||||
@@ -498,6 +502,264 @@ export class KnowledgeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versions for an entry (paginated)
|
||||
*/
|
||||
async findVersions(
|
||||
workspaceId: string,
|
||||
slug: string,
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<PaginatedVersions> {
|
||||
// Find the entry to get its ID
|
||||
const entry = await this.prisma.knowledgeEntry.findUnique({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException(
|
||||
`Knowledge entry with slug "${slug}" not found`
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Get total count
|
||||
const total = await this.prisma.knowledgeEntryVersion.count({
|
||||
where: { entryId: entry.id },
|
||||
});
|
||||
|
||||
// Get versions with author information
|
||||
const versions = await this.prisma.knowledgeEntryVersion.findMany({
|
||||
where: { entryId: entry.id },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
version: "desc",
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// Transform to response format
|
||||
const data: KnowledgeEntryVersionWithAuthor[] = versions.map((v) => ({
|
||||
id: v.id,
|
||||
entryId: v.entryId,
|
||||
version: v.version,
|
||||
title: v.title,
|
||||
content: v.content,
|
||||
summary: v.summary,
|
||||
createdAt: v.createdAt,
|
||||
createdBy: v.createdBy,
|
||||
changeNote: v.changeNote,
|
||||
author: v.author,
|
||||
}));
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific version of an entry
|
||||
*/
|
||||
async findVersion(
|
||||
workspaceId: string,
|
||||
slug: string,
|
||||
version: number
|
||||
): Promise<KnowledgeEntryVersionWithAuthor> {
|
||||
// Find the entry to get its ID
|
||||
const entry = await this.prisma.knowledgeEntry.findUnique({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException(
|
||||
`Knowledge entry with slug "${slug}" not found`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the specific version
|
||||
const versionData = await this.prisma.knowledgeEntryVersion.findUnique({
|
||||
where: {
|
||||
entryId_version: {
|
||||
entryId: entry.id,
|
||||
version,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!versionData) {
|
||||
throw new NotFoundException(
|
||||
`Version ${version} not found for entry "${slug}"`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: versionData.id,
|
||||
entryId: versionData.entryId,
|
||||
version: versionData.version,
|
||||
title: versionData.title,
|
||||
content: versionData.content,
|
||||
summary: versionData.summary,
|
||||
createdAt: versionData.createdAt,
|
||||
createdBy: versionData.createdBy,
|
||||
changeNote: versionData.changeNote,
|
||||
author: versionData.author,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a previous version of an entry
|
||||
*/
|
||||
async restoreVersion(
|
||||
workspaceId: string,
|
||||
slug: string,
|
||||
version: number,
|
||||
userId: string,
|
||||
changeNote?: string
|
||||
): Promise<KnowledgeEntryWithTags> {
|
||||
// Get the version to restore
|
||||
const versionToRestore = await this.findVersion(workspaceId, slug, version);
|
||||
|
||||
// Find the current entry
|
||||
const entry = await this.prisma.knowledgeEntry.findUnique({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
versions: {
|
||||
orderBy: {
|
||||
version: "desc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException(
|
||||
`Knowledge entry with slug "${slug}" not found`
|
||||
);
|
||||
}
|
||||
|
||||
// Render markdown for the restored content
|
||||
const contentHtml = await renderMarkdown(versionToRestore.content);
|
||||
|
||||
// Use transaction to ensure atomicity
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
// Update entry with restored content
|
||||
const updated = await tx.knowledgeEntry.update({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: versionToRestore.title,
|
||||
content: versionToRestore.content,
|
||||
contentHtml,
|
||||
summary: versionToRestore.summary,
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create new version for the restore operation
|
||||
const latestVersion = entry.versions[0];
|
||||
const nextVersion = latestVersion ? latestVersion.version + 1 : 1;
|
||||
|
||||
await tx.knowledgeEntryVersion.create({
|
||||
data: {
|
||||
entryId: updated.id,
|
||||
version: nextVersion,
|
||||
title: updated.title,
|
||||
content: updated.content,
|
||||
summary: updated.summary,
|
||||
createdBy: userId,
|
||||
changeNote:
|
||||
changeNote || `Restored from version ${version}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch with tags
|
||||
return tx.knowledgeEntry.findUnique({
|
||||
where: { id: updated.id },
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Failed to restore version");
|
||||
}
|
||||
|
||||
// Sync wiki links after restore
|
||||
await this.linkSync.syncLinks(workspaceId, result.id, result.content);
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
workspaceId: result.workspaceId,
|
||||
slug: result.slug,
|
||||
title: result.title,
|
||||
content: result.content,
|
||||
contentHtml: result.contentHtml,
|
||||
summary: result.summary,
|
||||
status: result.status,
|
||||
visibility: result.visibility,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
createdBy: result.createdBy,
|
||||
updatedBy: result.updatedBy,
|
||||
tags: result.tags.map((et) => ({
|
||||
id: et.tag.id,
|
||||
name: et.tag.name,
|
||||
slug: et.tag.slug,
|
||||
color: et.tag.color,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync tags for an entry (create missing tags, update associations)
|
||||
*/
|
||||
|
||||
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