import { Test, TestingModule } from "@nestjs/testing"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { LinkSyncService } from "./link-sync.service"; import { LinkResolutionService } from "./link-resolution.service"; import { PrismaService } from "../../prisma/prisma.service"; import * as wikiLinkParser from "../utils/wiki-link-parser"; // Mock the wiki-link parser vi.mock("../utils/wiki-link-parser"); const mockParseWikiLinks = vi.mocked(wikiLinkParser.parseWikiLinks); describe("LinkSyncService", () => { let service: LinkSyncService; let prisma: PrismaService; let linkResolver: LinkResolutionService; const mockWorkspaceId = "workspace-1"; const mockEntryId = "entry-1"; const mockTargetId = "entry-2"; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ LinkSyncService, { provide: PrismaService, useValue: { knowledgeLink: { findMany: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), }, $transaction: vi.fn((fn) => fn(prisma)), }, }, { provide: LinkResolutionService, useValue: { resolveLink: vi.fn(), resolveLinks: vi.fn(), }, }, ], }).compile(); service = module.get(LinkSyncService); prisma = module.get(PrismaService); linkResolver = module.get(LinkResolutionService); }); afterEach(() => { vi.clearAllMocks(); }); describe("syncLinks", () => { it("should be defined", () => { expect(service).toBeDefined(); }); it("should parse wiki links from content", async () => { const content = "This is a [[Test Link]] in content"; mockParseWikiLinks.mockReturnValue([ { raw: "[[Test Link]]", target: "Test Link", displayText: "Test Link", start: 10, end: 25, }, ]); vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId); vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]); vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any); await service.syncLinks(mockWorkspaceId, mockEntryId, content); expect(mockParseWikiLinks).toHaveBeenCalledWith(content); }); it("should create new links when parsing finds wiki links", async () => { const content = "This is a [[Test Link]] in content"; mockParseWikiLinks.mockReturnValue([ { raw: "[[Test Link]]", target: "Test Link", displayText: "Test Link", start: 10, end: 25, }, ]); vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId); vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]); vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({ id: "link-1", sourceId: mockEntryId, targetId: mockTargetId, linkText: "Test Link", displayText: "Test Link", positionStart: 10, positionEnd: 25, resolved: true, context: null, createdAt: new Date(), }); await service.syncLinks(mockWorkspaceId, mockEntryId, content); expect(prisma.knowledgeLink.create).toHaveBeenCalledWith({ data: { sourceId: mockEntryId, targetId: mockTargetId, linkText: "Test Link", displayText: "Test Link", positionStart: 10, positionEnd: 25, resolved: true, }, }); }); it("should skip unresolved links when target cannot be found", async () => { const content = "This is a [[Nonexistent Link]] in content"; mockParseWikiLinks.mockReturnValue([ { raw: "[[Nonexistent Link]]", target: "Nonexistent Link", displayText: "Nonexistent Link", start: 10, end: 32, }, ]); vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(null); vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]); const transactionSpy = vi.spyOn(prisma, "$transaction").mockResolvedValue(undefined); await service.syncLinks(mockWorkspaceId, mockEntryId, content); // Should not create any links when target cannot be resolved // (schema requires targetId to be non-null) expect(transactionSpy).toHaveBeenCalled(); const transactionFn = transactionSpy.mock.calls[0][0]; expect(typeof transactionFn).toBe("function"); }); it("should handle custom display text in links", async () => { const content = "This is a [[Target|Custom Display]] in content"; mockParseWikiLinks.mockReturnValue([ { raw: "[[Target|Custom Display]]", target: "Target", displayText: "Custom Display", start: 10, end: 35, }, ]); vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId); vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]); vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any); await service.syncLinks(mockWorkspaceId, mockEntryId, content); expect(prisma.knowledgeLink.create).toHaveBeenCalledWith({ data: { sourceId: mockEntryId, targetId: mockTargetId, linkText: "Target", displayText: "Custom Display", positionStart: 10, positionEnd: 35, resolved: true, }, }); }); it("should delete orphaned links not present in updated content", async () => { const content = "This is a [[New Link]] in content"; mockParseWikiLinks.mockReturnValue([ { raw: "[[New Link]]", target: "New Link", displayText: "New Link", start: 10, end: 22, }, ]); // Mock existing link that should be removed vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([ { id: "old-link-1", sourceId: mockEntryId, targetId: "old-target", linkText: "Old Link", displayText: "Old Link", positionStart: 5, positionEnd: 17, resolved: true, context: null, createdAt: new Date(), }, ] as any); vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId); vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any); vi.spyOn(prisma.knowledgeLink, "deleteMany").mockResolvedValue({ count: 1 }); await service.syncLinks(mockWorkspaceId, mockEntryId, content); expect(prisma.knowledgeLink.deleteMany).toHaveBeenCalledWith({ where: { sourceId: mockEntryId, id: { in: ["old-link-1"], }, }, }); }); it("should handle empty content by removing all links", async () => { const content = ""; mockParseWikiLinks.mockReturnValue([]); vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([ { id: "link-1", sourceId: mockEntryId, targetId: mockTargetId, linkText: "Link", displayText: "Link", positionStart: 10, positionEnd: 18, resolved: true, context: null, createdAt: new Date(), }, ] as any); vi.spyOn(prisma.knowledgeLink, "deleteMany").mockResolvedValue({ count: 1 }); await service.syncLinks(mockWorkspaceId, mockEntryId, content); expect(prisma.knowledgeLink.deleteMany).toHaveBeenCalledWith({ where: { sourceId: mockEntryId, id: { in: ["link-1"], }, }, }); }); it("should handle multiple links in content", async () => { const content = "Links: [[Link 1]] and [[Link 2]] and [[Link 3]]"; mockParseWikiLinks.mockReturnValue([ { raw: "[[Link 1]]", target: "Link 1", displayText: "Link 1", start: 7, end: 17, }, { raw: "[[Link 2]]", target: "Link 2", displayText: "Link 2", start: 22, end: 32, }, { raw: "[[Link 3]]", target: "Link 3", displayText: "Link 3", start: 37, end: 47, }, ]); vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId); vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]); vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any); await service.syncLinks(mockWorkspaceId, mockEntryId, content); expect(prisma.knowledgeLink.create).toHaveBeenCalledTimes(3); }); }); describe("getBacklinks", () => { it("should return all backlinks for an entry", async () => { const mockBacklinks = [ { id: "link-1", sourceId: "source-1", targetId: mockEntryId, linkText: "Link Text", displayText: "Link Text", positionStart: 10, positionEnd: 25, resolved: true, context: null, createdAt: new Date(), source: { id: "source-1", title: "Source Entry", slug: "source-entry", }, }, ]; vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue(mockBacklinks as any); const result = await service.getBacklinks(mockEntryId); expect(prisma.knowledgeLink.findMany).toHaveBeenCalledWith({ where: { targetId: mockEntryId, resolved: true, }, include: { source: { select: { id: true, title: true, slug: true, }, }, }, orderBy: { createdAt: "desc", }, }); expect(result).toEqual(mockBacklinks); }); it("should return empty array when no backlinks exist", async () => { vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]); const result = await service.getBacklinks(mockEntryId); expect(result).toEqual([]); }); }); describe("getUnresolvedLinks", () => { it("should return all unresolved links for a workspace", async () => { const mockUnresolvedLinks = [ { id: "link-1", sourceId: mockEntryId, targetId: null, linkText: "Unresolved Link", displayText: "Unresolved Link", positionStart: 10, positionEnd: 29, resolved: false, context: null, createdAt: new Date(), }, ]; vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue(mockUnresolvedLinks as any); const result = await service.getUnresolvedLinks(mockWorkspaceId); expect(prisma.knowledgeLink.findMany).toHaveBeenCalledWith({ where: { source: { workspaceId: mockWorkspaceId, }, resolved: false, }, include: { source: { select: { id: true, title: true, slug: true, }, }, }, }); expect(result).toEqual(mockUnresolvedLinks); }); }); });