Files
stack/apps/api/src/knowledge/services/link-sync.service.spec.ts
Jason Woltje 9820706be1
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
test(CI): fix all test failures from lint changes
Fixed test expectations to match new behavior after lint fixes:
- Updated null/undefined expectations to match ?? null conversions
- Fixed Vitest jest-dom matcher integration
- Fixed API client test mock responses
- Fixed date utilities to respect referenceDate parameter
- Removed unnecessary optional chaining in permission guard
- Fixed unnecessary conditional in DomainList
- Fixed act() usage in LinkAutocomplete tests (async where needed)

Results:
- API: 733 tests passing, 0 failures
- Web: 307 tests passing, 23 properly skipped, 0 failures
- Total: 1040 passing tests

Refs #CI-run-19

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 01:01:21 -06:00

394 lines
11 KiB
TypeScript

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>(LinkSyncService);
prisma = module.get<PrismaService>(PrismaService);
linkResolver = module.get<LinkResolutionService>(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);
});
});
});