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); prisma = module.get(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, }), }) ); }); }); });