feat(#71): implement graph data API
Implemented three new API endpoints for knowledge graph visualization: 1. GET /api/knowledge/graph - Full knowledge graph - Returns all entries and links with optional filtering - Supports filtering by tags, status, and node count limit - Includes orphan detection (entries with no links) 2. GET /api/knowledge/graph/stats - Graph statistics - Total entries and links counts - Orphan entries detection - Average links per entry - Top 10 most connected entries - Tag distribution across entries 3. GET /api/knowledge/graph/:slug - Entry-centered subgraph - Returns graph centered on specific entry - Supports depth parameter (1-5) for traversal distance - Includes all connected nodes up to specified depth New Files: - apps/api/src/knowledge/graph.controller.ts - apps/api/src/knowledge/graph.controller.spec.ts Modified Files: - apps/api/src/knowledge/dto/graph-query.dto.ts (added GraphFilterDto) - apps/api/src/knowledge/entities/graph.entity.ts (extended with new types) - apps/api/src/knowledge/services/graph.service.ts (added new methods) - apps/api/src/knowledge/services/graph.service.spec.ts (added tests) - apps/api/src/knowledge/knowledge.module.ts (registered controller) - apps/api/src/knowledge/dto/index.ts (exported new DTOs) - docs/scratchpads/71-graph-data-api.md (implementation notes) Test Coverage: 21 tests (all passing) - 14 service tests including orphan detection, filtering, statistics - 7 controller tests for all three endpoints Follows TDD principles with tests written before implementation. All code quality gates passed (lint, typecheck, tests). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
154
apps/api/src/knowledge/graph.controller.spec.ts
Normal file
154
apps/api/src/knowledge/graph.controller.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { KnowledgeGraphController } from "./graph.controller";
|
||||
import { GraphService } from "./services/graph.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
|
||||
describe("KnowledgeGraphController", () => {
|
||||
let controller: KnowledgeGraphController;
|
||||
let graphService: GraphService;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
const mockGraphService = {
|
||||
getFullGraph: vi.fn(),
|
||||
getGraphStats: vi.fn(),
|
||||
getEntryGraph: vi.fn(),
|
||||
getEntryGraphBySlug: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
knowledgeEntry: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [KnowledgeGraphController],
|
||||
providers: [
|
||||
{
|
||||
provide: GraphService,
|
||||
useValue: mockGraphService,
|
||||
},
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<KnowledgeGraphController>(KnowledgeGraphController);
|
||||
graphService = module.get<GraphService>(GraphService);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getFullGraph", () => {
|
||||
it("should return full graph without filters", async () => {
|
||||
const mockGraph = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
stats: { totalNodes: 0, totalEdges: 0, orphanCount: 0 },
|
||||
};
|
||||
mockGraphService.getFullGraph.mockResolvedValue(mockGraph);
|
||||
|
||||
const result = await controller.getFullGraph("workspace-1", {});
|
||||
|
||||
expect(graphService.getFullGraph).toHaveBeenCalledWith("workspace-1", {});
|
||||
expect(result).toEqual(mockGraph);
|
||||
});
|
||||
|
||||
it("should pass filters to service", async () => {
|
||||
const mockGraph = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
stats: { totalNodes: 0, totalEdges: 0, orphanCount: 0 },
|
||||
};
|
||||
mockGraphService.getFullGraph.mockResolvedValue(mockGraph);
|
||||
|
||||
const filters = {
|
||||
tags: ["tag-1"],
|
||||
status: "PUBLISHED",
|
||||
limit: 100,
|
||||
};
|
||||
|
||||
await controller.getFullGraph("workspace-1", filters);
|
||||
|
||||
expect(graphService.getFullGraph).toHaveBeenCalledWith("workspace-1", filters);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGraphStats", () => {
|
||||
it("should return graph statistics", async () => {
|
||||
const mockStats = {
|
||||
totalEntries: 10,
|
||||
totalLinks: 15,
|
||||
orphanEntries: 2,
|
||||
averageLinks: 1.5,
|
||||
mostConnectedEntries: [],
|
||||
tagDistribution: [],
|
||||
};
|
||||
mockGraphService.getGraphStats.mockResolvedValue(mockStats);
|
||||
|
||||
const result = await controller.getGraphStats("workspace-1");
|
||||
|
||||
expect(graphService.getGraphStats).toHaveBeenCalledWith("workspace-1");
|
||||
expect(result).toEqual(mockStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntryGraph", () => {
|
||||
it("should return entry-centered graph", async () => {
|
||||
const mockEntry = {
|
||||
id: "entry-1",
|
||||
slug: "test-entry",
|
||||
title: "Test Entry",
|
||||
};
|
||||
|
||||
const mockGraph = {
|
||||
centerNode: mockEntry,
|
||||
nodes: [mockEntry],
|
||||
edges: [],
|
||||
stats: { totalNodes: 1, totalEdges: 0, maxDepth: 1 },
|
||||
};
|
||||
|
||||
mockGraphService.getEntryGraphBySlug.mockResolvedValue(mockGraph);
|
||||
|
||||
const result = await controller.getEntryGraph("workspace-1", "test-entry", { depth: 2 });
|
||||
|
||||
expect(graphService.getEntryGraphBySlug).toHaveBeenCalledWith("workspace-1", "test-entry", 2);
|
||||
expect(result).toEqual(mockGraph);
|
||||
});
|
||||
|
||||
it("should use default depth if not provided", async () => {
|
||||
mockGraphService.getEntryGraphBySlug.mockResolvedValue({});
|
||||
|
||||
await controller.getEntryGraph("workspace-1", "test-entry", {});
|
||||
|
||||
expect(graphService.getEntryGraphBySlug).toHaveBeenCalledWith("workspace-1", "test-entry", 1);
|
||||
});
|
||||
|
||||
it("should throw error if entry not found", async () => {
|
||||
mockGraphService.getEntryGraphBySlug.mockRejectedValue(new Error("Entry not found"));
|
||||
|
||||
await expect(
|
||||
controller.getEntryGraph("workspace-1", "non-existent", {})
|
||||
).rejects.toThrow("Entry not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user