Files
stack/apps/api/src/knowledge/services/link-resolution.service.spec.ts
Jason Woltje 24768bd664 feat(knowledge): add link resolution service
- 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)
2026-01-29 19:34:57 -06:00

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