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:
346
apps/orchestrator/src/git/worktree-manager.service.spec.ts
Normal file
346
apps/orchestrator/src/git/worktree-manager.service.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorktreeManagerService } from "./worktree-manager.service";
|
||||
import { GitOperationsService } from "./git-operations.service";
|
||||
import { WorktreeError } from "./types";
|
||||
import * as path from "path";
|
||||
|
||||
// Mock simple-git
|
||||
const mockGit = {
|
||||
raw: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("simple-git", () => ({
|
||||
simpleGit: vi.fn(() => mockGit),
|
||||
}));
|
||||
|
||||
describe("WorktreeManagerService", () => {
|
||||
let service: WorktreeManagerService;
|
||||
let mockConfigService: ConfigService;
|
||||
let mockGitOperationsService: GitOperationsService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock config service
|
||||
mockConfigService = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "orchestrator.git.userName") return "Test User";
|
||||
if (key === "orchestrator.git.userEmail") return "test@example.com";
|
||||
return undefined;
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// Create mock git operations service
|
||||
mockGitOperationsService = new GitOperationsService(mockConfigService);
|
||||
|
||||
// Create service with mocks
|
||||
service = new WorktreeManagerService(mockGitOperationsService);
|
||||
});
|
||||
|
||||
describe("createWorktree", () => {
|
||||
it("should create worktree with correct naming convention", async () => {
|
||||
const repoPath = "/tmp/test-repo";
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const expectedPath = path.join(
|
||||
"/tmp",
|
||||
"test-repo_worktrees",
|
||||
`agent-${agentId}-${taskId}`,
|
||||
);
|
||||
const branchName = `agent-${agentId}-${taskId}`;
|
||||
|
||||
mockGit.raw.mockResolvedValue(
|
||||
`worktree ${expectedPath}\nHEAD abc123\nbranch refs/heads/${branchName}`,
|
||||
);
|
||||
|
||||
const result = await service.createWorktree(repoPath, agentId, taskId);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.path).toBe(expectedPath);
|
||||
expect(result.branch).toBe(branchName);
|
||||
expect(mockGit.raw).toHaveBeenCalledWith([
|
||||
"worktree",
|
||||
"add",
|
||||
expectedPath,
|
||||
"-b",
|
||||
branchName,
|
||||
"develop",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create worktree with custom base branch", async () => {
|
||||
const repoPath = "/tmp/test-repo";
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const baseBranch = "main";
|
||||
const expectedPath = path.join(
|
||||
"/tmp",
|
||||
"test-repo_worktrees",
|
||||
`agent-${agentId}-${taskId}`,
|
||||
);
|
||||
const branchName = `agent-${agentId}-${taskId}`;
|
||||
|
||||
mockGit.raw.mockResolvedValue(
|
||||
`worktree ${expectedPath}\nHEAD abc123\nbranch refs/heads/${branchName}`,
|
||||
);
|
||||
|
||||
await service.createWorktree(repoPath, agentId, taskId, baseBranch);
|
||||
|
||||
expect(mockGit.raw).toHaveBeenCalledWith([
|
||||
"worktree",
|
||||
"add",
|
||||
expectedPath,
|
||||
"-b",
|
||||
branchName,
|
||||
baseBranch,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw WorktreeError if worktree already exists", async () => {
|
||||
const error = new Error("fatal: 'agent-123-task-456' already exists");
|
||||
mockGit.raw.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.createWorktree("/tmp/test-repo", "agent-123", "task-456"),
|
||||
).rejects.toThrow(WorktreeError);
|
||||
|
||||
try {
|
||||
await service.createWorktree("/tmp/test-repo", "agent-123", "task-456");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(WorktreeError);
|
||||
expect((e as WorktreeError).operation).toBe("createWorktree");
|
||||
expect((e as WorktreeError).cause).toBe(error);
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw WorktreeError on git command failure", async () => {
|
||||
const error = new Error("git command failed");
|
||||
mockGit.raw.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.createWorktree("/tmp/test-repo", "agent-123", "task-456"),
|
||||
).rejects.toThrow(WorktreeError);
|
||||
});
|
||||
|
||||
it("should validate agentId is not empty", async () => {
|
||||
await expect(
|
||||
service.createWorktree("/tmp/test-repo", "", "task-456"),
|
||||
).rejects.toThrow("agentId is required");
|
||||
});
|
||||
|
||||
it("should validate taskId is not empty", async () => {
|
||||
await expect(
|
||||
service.createWorktree("/tmp/test-repo", "agent-123", ""),
|
||||
).rejects.toThrow("taskId is required");
|
||||
});
|
||||
|
||||
it("should validate repoPath is not empty", async () => {
|
||||
await expect(
|
||||
service.createWorktree("", "agent-123", "task-456"),
|
||||
).rejects.toThrow("repoPath is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeWorktree", () => {
|
||||
it("should remove worktree successfully", async () => {
|
||||
const worktreePath = "/tmp/test-repo_worktrees/agent-123-task-456";
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
await service.removeWorktree(worktreePath);
|
||||
|
||||
expect(mockGit.raw).toHaveBeenCalledWith([
|
||||
"worktree",
|
||||
"remove",
|
||||
worktreePath,
|
||||
"--force",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle non-existent worktree gracefully", async () => {
|
||||
const worktreePath = "/tmp/test-repo_worktrees/non-existent";
|
||||
const error = new Error("fatal: 'non-existent' is not a working tree");
|
||||
mockGit.raw.mockRejectedValue(error);
|
||||
|
||||
// Should not throw, just log warning
|
||||
await expect(service.removeWorktree(worktreePath)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw WorktreeError on removal failure", async () => {
|
||||
const worktreePath = "/tmp/test-repo_worktrees/agent-123-task-456";
|
||||
const error = new Error("permission denied");
|
||||
mockGit.raw.mockRejectedValue(error);
|
||||
|
||||
// Should throw for non-worktree-not-found errors
|
||||
await expect(service.removeWorktree(worktreePath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should validate worktreePath is not empty", async () => {
|
||||
await expect(service.removeWorktree("")).rejects.toThrow(
|
||||
"worktreePath is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listWorktrees", () => {
|
||||
it("should return empty array when no worktrees exist", async () => {
|
||||
const repoPath = "/tmp/test-repo";
|
||||
mockGit.raw.mockResolvedValue(`/tmp/test-repo abc123 [develop]`);
|
||||
|
||||
const result = await service.listWorktrees(repoPath);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should list all active worktrees", async () => {
|
||||
const repoPath = "/tmp/test-repo";
|
||||
const output = `/tmp/test-repo abc123 [develop]
|
||||
/tmp/test-repo_worktrees/agent-123-task-456 def456 [agent-123-task-456]
|
||||
/tmp/test-repo_worktrees/agent-789-task-012 abc789 [agent-789-task-012]`;
|
||||
|
||||
mockGit.raw.mockResolvedValue(output);
|
||||
|
||||
const result = await service.listWorktrees(repoPath);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].path).toBe(
|
||||
"/tmp/test-repo_worktrees/agent-123-task-456",
|
||||
);
|
||||
expect(result[0].commit).toBe("def456");
|
||||
expect(result[0].branch).toBe("agent-123-task-456");
|
||||
expect(result[1].path).toBe(
|
||||
"/tmp/test-repo_worktrees/agent-789-task-012",
|
||||
);
|
||||
expect(result[1].commit).toBe("abc789");
|
||||
expect(result[1].branch).toBe("agent-789-task-012");
|
||||
});
|
||||
|
||||
it("should parse worktree info correctly", async () => {
|
||||
const repoPath = "/tmp/test-repo";
|
||||
const output = `/tmp/test-repo abc123 [develop]
|
||||
/tmp/test-repo_worktrees/agent-123-task-456 def456 [agent-123-task-456]`;
|
||||
|
||||
mockGit.raw.mockResolvedValue(output);
|
||||
|
||||
const result = await service.listWorktrees(repoPath);
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
path: "/tmp/test-repo_worktrees/agent-123-task-456",
|
||||
commit: "def456",
|
||||
branch: "agent-123-task-456",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw WorktreeError on git command failure", async () => {
|
||||
const error = new Error("git command failed");
|
||||
mockGit.raw.mockRejectedValue(error);
|
||||
|
||||
await expect(service.listWorktrees("/tmp/test-repo")).rejects.toThrow(
|
||||
WorktreeError,
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate repoPath is not empty", async () => {
|
||||
await expect(service.listWorktrees("")).rejects.toThrow(
|
||||
"repoPath is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupWorktree", () => {
|
||||
it("should remove worktree on agent completion", async () => {
|
||||
const repoPath = "/tmp/test-repo";
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const worktreePath = path.join(
|
||||
"/tmp",
|
||||
"test-repo_worktrees",
|
||||
`agent-${agentId}-${taskId}`,
|
||||
);
|
||||
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
await service.cleanupWorktree(repoPath, agentId, taskId);
|
||||
|
||||
expect(mockGit.raw).toHaveBeenCalledWith([
|
||||
"worktree",
|
||||
"remove",
|
||||
worktreePath,
|
||||
"--force",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle cleanup errors gracefully", async () => {
|
||||
const error = new Error("worktree not found");
|
||||
mockGit.raw.mockRejectedValue(error);
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
service.cleanupWorktree("/tmp/test-repo", "agent-123", "task-456"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should validate agentId is not empty", async () => {
|
||||
await expect(
|
||||
service.cleanupWorktree("/tmp/test-repo", "", "task-456"),
|
||||
).rejects.toThrow("agentId is required");
|
||||
});
|
||||
|
||||
it("should validate taskId is not empty", async () => {
|
||||
await expect(
|
||||
service.cleanupWorktree("/tmp/test-repo", "agent-123", ""),
|
||||
).rejects.toThrow("taskId is required");
|
||||
});
|
||||
|
||||
it("should validate repoPath is not empty", async () => {
|
||||
await expect(
|
||||
service.cleanupWorktree("", "agent-123", "task-456"),
|
||||
).rejects.toThrow("repoPath is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorktreePath", () => {
|
||||
it("should generate correct worktree path", () => {
|
||||
const repoPath = "/tmp/test-repo";
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const expectedPath = path.join(
|
||||
"/tmp",
|
||||
"test-repo_worktrees",
|
||||
`agent-${agentId}-${taskId}`,
|
||||
);
|
||||
|
||||
const result = service.getWorktreePath(repoPath, agentId, taskId);
|
||||
|
||||
expect(result).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it("should handle repo paths with trailing slashes", () => {
|
||||
const repoPath = "/tmp/test-repo/";
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const expectedPath = path.join(
|
||||
"/tmp",
|
||||
"test-repo_worktrees",
|
||||
`agent-${agentId}-${taskId}`,
|
||||
);
|
||||
|
||||
const result = service.getWorktreePath(repoPath, agentId, taskId);
|
||||
|
||||
expect(result).toBe(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBranchName", () => {
|
||||
it("should generate correct branch name", () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const expectedBranch = `agent-${agentId}-${taskId}`;
|
||||
|
||||
const result = service.getBranchName(agentId, taskId);
|
||||
|
||||
expect(result).toBe(expectedBranch);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user