import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { NotFoundException } from "@nestjs/common"; import { GraphService } from "./graph.service"; import { PrismaService } from "../../prisma/prisma.service"; import { KnowledgeCacheService } from "./cache.service"; describe("GraphService", () => { let service: GraphService; let prisma: PrismaService; const mockEntry = { id: "entry-1", workspaceId: "workspace-1", slug: "test-entry", title: "Test Entry", content: "Test content", contentHtml: "

Test content

", summary: "Test summary", status: "PUBLISHED", visibility: "WORKSPACE", createdAt: new Date(), updatedAt: new Date(), createdBy: "user-1", updatedBy: "user-1", tags: [], outgoingLinks: [], incomingLinks: [], }; const mockPrismaService = { knowledgeEntry: { findUnique: vi.fn(), }, }; const mockCacheService = { isEnabled: vi.fn().mockReturnValue(false), getEntry: vi.fn().mockResolvedValue(null), setEntry: vi.fn(), invalidateEntry: vi.fn(), getGraph: vi.fn().mockResolvedValue(null), setGraph: vi.fn(), invalidateGraph: vi.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ GraphService, { provide: PrismaService, useValue: mockPrismaService, }, { provide: KnowledgeCacheService, useValue: mockCacheService, }, ], }).compile(); service = module.get(GraphService); prisma = module.get(PrismaService); vi.clearAllMocks(); }); it("should be defined", () => { expect(service).toBeDefined(); }); describe("getEntryGraphBySlug", () => { it("should throw NotFoundException if entry does not exist", async () => { mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null); await expect(service.getEntryGraphBySlug("workspace-1", "non-existent", 1)).rejects.toThrow( NotFoundException ); }); it("should call getEntryGraph with entry ID", async () => { const mockEntry = { id: "entry-1", workspaceId: "workspace-1", slug: "test-entry", tags: [], outgoingLinks: [], incomingLinks: [], }; mockPrismaService.knowledgeEntry.findUnique .mockResolvedValueOnce(mockEntry) // First call in getEntryGraphBySlug .mockResolvedValueOnce(mockEntry) // Second call in getEntryGraph validation .mockResolvedValueOnce(mockEntry); // Third call in getEntryGraph BFS await service.getEntryGraphBySlug("workspace-1", "test-entry", 1); expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith({ where: { workspaceId_slug: { workspaceId: "workspace-1", slug: "test-entry", }, }, }); }); }); describe("getEntryGraph", () => { it("should throw NotFoundException if entry does not exist", async () => { mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null); await expect(service.getEntryGraph("workspace-1", "non-existent", 1)).rejects.toThrow( NotFoundException ); }); it("should throw NotFoundException if entry belongs to different workspace", async () => { mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue({ ...mockEntry, workspaceId: "different-workspace", }); await expect(service.getEntryGraph("workspace-1", "entry-1", 1)).rejects.toThrow( NotFoundException ); }); it("should return graph with center node when depth is 0", async () => { mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry); const result = await service.getEntryGraph("workspace-1", "entry-1", 0); expect(result.centerNode.id).toBe("entry-1"); expect(result.nodes).toHaveLength(1); expect(result.edges).toHaveLength(0); expect(result.stats.totalNodes).toBe(1); expect(result.stats.totalEdges).toBe(0); }); it("should build graph with connected nodes at depth 1", async () => { const linkedEntry = { id: "entry-2", workspaceId: "workspace-1", slug: "linked-entry", title: "Linked Entry", content: "Linked content", contentHtml: "

Linked content

", summary: null, status: "PUBLISHED", visibility: "WORKSPACE", createdAt: new Date(), updatedAt: new Date(), createdBy: "user-1", updatedBy: "user-1", tags: [], outgoingLinks: [], incomingLinks: [], }; mockPrismaService.knowledgeEntry.findUnique // First call: initial validation (with tags only) .mockResolvedValueOnce(mockEntry) // Second call: BFS for center entry (with tags and links) .mockResolvedValueOnce({ ...mockEntry, outgoingLinks: [ { id: "link-1", sourceId: "entry-1", targetId: "entry-2", linkText: "link to entry 2", resolved: true, target: linkedEntry, }, ], incomingLinks: [], }) // Third call: BFS for linked entry .mockResolvedValueOnce(linkedEntry); const result = await service.getEntryGraph("workspace-1", "entry-1", 1); expect(result.nodes).toHaveLength(2); expect(result.edges).toHaveLength(1); expect(result.stats.totalNodes).toBe(2); expect(result.stats.totalEdges).toBe(1); }); }); describe("getFullGraph", () => { beforeEach(() => { // Add findMany mock mockPrismaService.knowledgeEntry.findMany = vi.fn(); mockPrismaService.knowledgeLink = { findMany: vi.fn(), }; }); it("should return full graph with all entries and links", async () => { const entries = [ { ...mockEntry, id: "entry-1", slug: "entry-1", tags: [] }, { ...mockEntry, id: "entry-2", slug: "entry-2", title: "Entry 2", tags: [], }, ]; const links = [ { id: "link-1", sourceId: "entry-1", targetId: "entry-2", linkText: "link text", resolved: true, }, ]; mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries); mockPrismaService.knowledgeLink.findMany.mockResolvedValue(links); const result = await service.getFullGraph("workspace-1"); expect(result.nodes).toHaveLength(2); expect(result.edges).toHaveLength(1); expect(result.stats.totalNodes).toBe(2); expect(result.stats.totalEdges).toBe(1); expect(result.stats.orphanCount).toBe(0); }); it("should detect orphan entries (entries with no links)", async () => { const entries = [ { ...mockEntry, id: "entry-1", slug: "entry-1", tags: [] }, { ...mockEntry, id: "entry-2", slug: "entry-2", title: "Entry 2", tags: [], }, { ...mockEntry, id: "entry-3", slug: "entry-3", title: "Entry 3 (orphan)", tags: [], }, ]; const links = [ { id: "link-1", sourceId: "entry-1", targetId: "entry-2", linkText: "link text", resolved: true, }, ]; mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries); mockPrismaService.knowledgeLink.findMany.mockResolvedValue(links); const result = await service.getFullGraph("workspace-1"); expect(result.stats.orphanCount).toBe(1); const orphanNode = result.nodes.find((n) => n.id === "entry-3"); expect(orphanNode?.isOrphan).toBe(true); }); it("should filter by status", async () => { const entries = [{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] }]; mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries); mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]); await service.getFullGraph("workspace-1", { status: "PUBLISHED" }); expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: "PUBLISHED", }), }) ); }); it("should filter by tags", async () => { const entries = [{ ...mockEntry, id: "entry-1", tags: [] }]; mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries); mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]); await service.getFullGraph("workspace-1", { tags: ["tag-1", "tag-2"] }); expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ tags: { some: { tag: { slug: { in: ["tag-1", "tag-2"], }, }, }, }, }), }) ); }); it("should limit number of nodes", async () => { const entries = [ { ...mockEntry, id: "entry-1", slug: "entry-1", tags: [] }, { ...mockEntry, id: "entry-2", slug: "entry-2", tags: [] }, ]; mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries); mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]); await service.getFullGraph("workspace-1", { limit: 1 }); expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ take: 1, }) ); }); }); describe("getGraphStats", () => { beforeEach(() => { mockPrismaService.knowledgeEntry.count = vi.fn(); mockPrismaService.knowledgeEntry.findMany = vi.fn(); mockPrismaService.knowledgeLink = { count: vi.fn(), groupBy: vi.fn(), }; mockPrismaService.$queryRaw = vi.fn(); }); it("should return graph statistics", async () => { mockPrismaService.knowledgeEntry.count.mockResolvedValue(10); mockPrismaService.knowledgeLink.count.mockResolvedValue(15); mockPrismaService.$queryRaw.mockResolvedValue([ { id: "entry-1", slug: "entry-1", title: "Entry 1", link_count: "5" }, { id: "entry-2", slug: "entry-2", title: "Entry 2", link_count: "3" }, ]); mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([{ id: "orphan-1" }]); const result = await service.getGraphStats("workspace-1"); expect(result.totalEntries).toBe(10); expect(result.totalLinks).toBe(15); expect(result.averageLinks).toBe(1.5); expect(result.mostConnectedEntries).toHaveLength(2); expect(result.mostConnectedEntries[0].linkCount).toBe(5); }); it("should calculate orphan entries correctly", async () => { mockPrismaService.knowledgeEntry.count.mockResolvedValue(5); mockPrismaService.knowledgeLink.count.mockResolvedValue(2); mockPrismaService.$queryRaw.mockResolvedValue([]); mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([ { id: "orphan-1" }, { id: "orphan-2" }, ]); const result = await service.getGraphStats("workspace-1"); expect(result.orphanEntries).toBe(2); }); }); });