- Add resolveLinksFromContent() to parse wiki links from content and resolve them - Add getBacklinks() to find all entries that link to a target entry - Import parseWikiLinks from utils for content parsing - Export new types: ResolvedLink, Backlink - Add comprehensive tests for new functionality (27 tests total)
592 lines
18 KiB
TypeScript
592 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { LinkResolutionService } from "./link-resolution.service";
|
|
import { PrismaService } from "../../prisma/prisma.service";
|
|
|
|
describe("LinkResolutionService", () => {
|
|
let service: LinkResolutionService;
|
|
let prisma: PrismaService;
|
|
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockEntries = [
|
|
{
|
|
id: "entry-1",
|
|
workspaceId,
|
|
slug: "typescript-guide",
|
|
title: "TypeScript Guide",
|
|
content: "...",
|
|
contentHtml: "...",
|
|
summary: null,
|
|
status: "PUBLISHED",
|
|
visibility: "WORKSPACE",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
createdBy: "user-1",
|
|
updatedBy: "user-1",
|
|
},
|
|
{
|
|
id: "entry-2",
|
|
workspaceId,
|
|
slug: "react-hooks",
|
|
title: "React Hooks",
|
|
content: "...",
|
|
contentHtml: "...",
|
|
summary: null,
|
|
status: "PUBLISHED",
|
|
visibility: "WORKSPACE",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
createdBy: "user-1",
|
|
updatedBy: "user-1",
|
|
},
|
|
{
|
|
id: "entry-3",
|
|
workspaceId,
|
|
slug: "react-hooks-advanced",
|
|
title: "React Hooks Advanced",
|
|
content: "...",
|
|
contentHtml: "...",
|
|
summary: null,
|
|
status: "PUBLISHED",
|
|
visibility: "WORKSPACE",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
createdBy: "user-1",
|
|
updatedBy: "user-1",
|
|
},
|
|
];
|
|
|
|
const mockPrismaService = {
|
|
knowledgeEntry: {
|
|
findUnique: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
knowledgeLink: {
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
LinkResolutionService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrismaService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<LinkResolutionService>(LinkResolutionService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("resolveLink", () => {
|
|
describe("Exact title match", () => {
|
|
it("should resolve link by exact title match", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
|
mockEntries[0]
|
|
);
|
|
|
|
const result = await service.resolveLink(
|
|
workspaceId,
|
|
"TypeScript Guide"
|
|
);
|
|
|
|
expect(result).toBe("entry-1");
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
|
{
|
|
where: {
|
|
workspaceId,
|
|
title: "TypeScript Guide",
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
}
|
|
);
|
|
});
|
|
|
|
it("should be case-sensitive for exact title match", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.resolveLink(
|
|
workspaceId,
|
|
"typescript guide"
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Slug match", () => {
|
|
it("should resolve link by slug", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(
|
|
mockEntries[0]
|
|
);
|
|
|
|
const result = await service.resolveLink(
|
|
workspaceId,
|
|
"typescript-guide"
|
|
);
|
|
|
|
expect(result).toBe("entry-1");
|
|
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith(
|
|
{
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug: "typescript-guide",
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
}
|
|
);
|
|
});
|
|
|
|
it("should prioritize exact title match over slug match", async () => {
|
|
// If exact title matches, slug should not be checked
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
|
mockEntries[0]
|
|
);
|
|
|
|
const result = await service.resolveLink(
|
|
workspaceId,
|
|
"TypeScript Guide"
|
|
);
|
|
|
|
expect(result).toBe("entry-1");
|
|
expect(mockPrismaService.knowledgeEntry.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Fuzzy title match", () => {
|
|
it("should resolve link by case-insensitive fuzzy match", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
|
mockEntries[0],
|
|
]);
|
|
|
|
const result = await service.resolveLink(
|
|
workspaceId,
|
|
"typescript guide"
|
|
);
|
|
|
|
expect(result).toBe("entry-1");
|
|
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId,
|
|
title: {
|
|
mode: "insensitive",
|
|
equals: "typescript guide",
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should return null when fuzzy match finds multiple results", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
|
mockEntries[1],
|
|
mockEntries[2],
|
|
]);
|
|
|
|
const result = await service.resolveLink(workspaceId, "react hooks");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should return null when no match is found", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.resolveLink(
|
|
workspaceId,
|
|
"Non-existent Entry"
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Workspace scoping", () => {
|
|
it("should only resolve links within the specified workspace", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
await service.resolveLink("different-workspace", "TypeScript Guide");
|
|
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
workspaceId: "different-workspace",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Edge cases", () => {
|
|
it("should handle empty string input", async () => {
|
|
const result = await service.resolveLink(workspaceId, "");
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle null input", async () => {
|
|
const result = await service.resolveLink(workspaceId, null as any);
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle whitespace-only input", async () => {
|
|
const result = await service.resolveLink(workspaceId, " ");
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should trim whitespace from target before resolving", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
|
mockEntries[0]
|
|
);
|
|
|
|
const result = await service.resolveLink(
|
|
workspaceId,
|
|
" TypeScript Guide "
|
|
);
|
|
|
|
expect(result).toBe("entry-1");
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
title: "TypeScript Guide",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveLinks", () => {
|
|
it("should resolve multiple links in batch", async () => {
|
|
// First link: "TypeScript Guide" -> exact title match
|
|
// Second link: "react-hooks" -> slug match
|
|
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(
|
|
async ({ where }: any) => {
|
|
if (where.title === "TypeScript Guide") {
|
|
return mockEntries[0];
|
|
}
|
|
return null;
|
|
}
|
|
);
|
|
|
|
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(
|
|
async ({ where }: any) => {
|
|
if (where.workspaceId_slug?.slug === "react-hooks") {
|
|
return mockEntries[1];
|
|
}
|
|
return null;
|
|
}
|
|
);
|
|
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
|
|
|
const targets = ["TypeScript Guide", "react-hooks"];
|
|
const result = await service.resolveLinks(workspaceId, targets);
|
|
|
|
expect(result).toEqual({
|
|
"TypeScript Guide": "entry-1",
|
|
"react-hooks": "entry-2",
|
|
});
|
|
});
|
|
|
|
it("should handle empty array", async () => {
|
|
const result = await service.resolveLinks(workspaceId, []);
|
|
|
|
expect(result).toEqual({});
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle unresolved links", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValue(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
|
|
|
const result = await service.resolveLinks(workspaceId, [
|
|
"Non-existent",
|
|
"Another-Non-existent",
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
"Non-existent": null,
|
|
"Another-Non-existent": null,
|
|
});
|
|
});
|
|
|
|
it("should deduplicate targets", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
|
mockEntries[0]
|
|
);
|
|
|
|
const result = await service.resolveLinks(workspaceId, [
|
|
"TypeScript Guide",
|
|
"TypeScript Guide",
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
"TypeScript Guide": "entry-1",
|
|
});
|
|
// Should only be called once for the deduplicated target
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(
|
|
1
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getAmbiguousMatches", () => {
|
|
it("should return multiple entries that match case-insensitively", async () => {
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
|
{ id: "entry-2", title: "React Hooks" },
|
|
{ id: "entry-3", title: "React Hooks Advanced" },
|
|
]);
|
|
|
|
const result = await service.getAmbiguousMatches(
|
|
workspaceId,
|
|
"react hooks"
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toEqual([
|
|
{ id: "entry-2", title: "React Hooks" },
|
|
{ id: "entry-3", title: "React Hooks Advanced" },
|
|
]);
|
|
});
|
|
|
|
it("should return empty array when no matches found", async () => {
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.getAmbiguousMatches(
|
|
workspaceId,
|
|
"Non-existent"
|
|
);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should return single entry if only one match", async () => {
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
|
{ id: "entry-1", title: "TypeScript Guide" },
|
|
]);
|
|
|
|
const result = await service.getAmbiguousMatches(
|
|
workspaceId,
|
|
"typescript guide"
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("resolveLinksFromContent", () => {
|
|
it("should parse and resolve wiki links from content", async () => {
|
|
const content =
|
|
"Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
|
|
|
|
// Mock resolveLink for each target
|
|
mockPrismaService.knowledgeEntry.findFirst
|
|
.mockResolvedValueOnce({ id: "entry-1" }) // TypeScript Guide
|
|
.mockResolvedValueOnce({ id: "entry-2" }); // React Hooks
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].link.target).toBe("TypeScript Guide");
|
|
expect(result[0].entryId).toBe("entry-1");
|
|
expect(result[1].link.target).toBe("React Hooks");
|
|
expect(result[1].entryId).toBe("entry-2");
|
|
});
|
|
|
|
it("should return null entryId for unresolved links", async () => {
|
|
const content = "See [[Non-existent Page]] for details.";
|
|
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].link.target).toBe("Non-existent Page");
|
|
expect(result[0].entryId).toBeNull();
|
|
});
|
|
|
|
it("should return empty array for content with no wiki links", async () => {
|
|
const content = "This content has no wiki links.";
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toEqual([]);
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle content with display text syntax", async () => {
|
|
const content = "Read the [[typescript-guide|TS Guide]] first.";
|
|
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce({
|
|
id: "entry-1",
|
|
});
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].link.target).toBe("typescript-guide");
|
|
expect(result[0].link.displayText).toBe("TS Guide");
|
|
expect(result[0].entryId).toBe("entry-1");
|
|
});
|
|
|
|
it("should preserve link position information", async () => {
|
|
const content = "Start [[Page One]] middle [[Page Two]] end.";
|
|
|
|
mockPrismaService.knowledgeEntry.findFirst
|
|
.mockResolvedValueOnce({ id: "entry-1" })
|
|
.mockResolvedValueOnce({ id: "entry-2" });
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].link.start).toBe(6);
|
|
expect(result[0].link.end).toBe(18);
|
|
expect(result[1].link.start).toBe(26);
|
|
expect(result[1].link.end).toBe(38);
|
|
});
|
|
});
|
|
|
|
describe("getBacklinks", () => {
|
|
it("should return all entries that link to the target entry", async () => {
|
|
const targetEntryId = "entry-target";
|
|
const mockBacklinks = [
|
|
{
|
|
id: "link-1",
|
|
sourceId: "entry-1",
|
|
targetId: targetEntryId,
|
|
linkText: "Target Page",
|
|
displayText: "Target Page",
|
|
positionStart: 10,
|
|
positionEnd: 25,
|
|
resolved: true,
|
|
context: null,
|
|
createdAt: new Date(),
|
|
source: {
|
|
id: "entry-1",
|
|
title: "TypeScript Guide",
|
|
slug: "typescript-guide",
|
|
},
|
|
},
|
|
{
|
|
id: "link-2",
|
|
sourceId: "entry-2",
|
|
targetId: targetEntryId,
|
|
linkText: "Target Page",
|
|
displayText: "See Target",
|
|
positionStart: 50,
|
|
positionEnd: 70,
|
|
resolved: true,
|
|
context: null,
|
|
createdAt: new Date(),
|
|
source: {
|
|
id: "entry-2",
|
|
title: "React Hooks",
|
|
slug: "react-hooks",
|
|
},
|
|
},
|
|
];
|
|
|
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(
|
|
mockBacklinks
|
|
);
|
|
|
|
const result = await service.getBacklinks(targetEntryId);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toEqual({
|
|
sourceId: "entry-1",
|
|
sourceTitle: "TypeScript Guide",
|
|
sourceSlug: "typescript-guide",
|
|
linkText: "Target Page",
|
|
displayText: "Target Page",
|
|
});
|
|
expect(result[1]).toEqual({
|
|
sourceId: "entry-2",
|
|
sourceTitle: "React Hooks",
|
|
sourceSlug: "react-hooks",
|
|
linkText: "Target Page",
|
|
displayText: "See Target",
|
|
});
|
|
|
|
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
targetId: targetEntryId,
|
|
resolved: true,
|
|
},
|
|
include: {
|
|
source: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should return empty array when no backlinks exist", async () => {
|
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.getBacklinks("entry-with-no-backlinks");
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should only return resolved backlinks", async () => {
|
|
const targetEntryId = "entry-target";
|
|
|
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
|
|
|
|
await service.getBacklinks(targetEntryId);
|
|
|
|
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
resolved: true,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|