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:
412
apps/orchestrator/src/git/conflict-detection.service.spec.ts
Normal file
412
apps/orchestrator/src/git/conflict-detection.service.spec.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConflictDetectionService } from "./conflict-detection.service";
|
||||
import { ConflictDetectionError } from "./types";
|
||||
|
||||
// Mock simple-git
|
||||
const mockGit = {
|
||||
fetch: vi.fn(),
|
||||
status: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("simple-git", () => ({
|
||||
simpleGit: vi.fn(() => mockGit),
|
||||
}));
|
||||
|
||||
describe("ConflictDetectionService", () => {
|
||||
let service: ConflictDetectionService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create service
|
||||
service = new ConflictDetectionService();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("checkForConflicts", () => {
|
||||
it("should return no conflicts when branches can merge cleanly", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - no conflicts
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
// Mock status - no conflicted files
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(false);
|
||||
expect(result.conflicts).toHaveLength(0);
|
||||
expect(result.strategy).toBe("merge");
|
||||
expect(result.remoteBranch).toBe("develop");
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
||||
});
|
||||
|
||||
it("should detect merge conflicts", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - conflicts detected
|
||||
mockGit.raw.mockRejectedValueOnce(
|
||||
new Error("CONFLICT (content): Merge conflict in file.ts"),
|
||||
);
|
||||
|
||||
// Mock status - show conflicted files
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts", "src/other.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
{
|
||||
path: "src/other.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock merge abort (cleanup)
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(2);
|
||||
expect(result.conflicts[0].file).toBe("src/file.ts");
|
||||
expect(result.conflicts[0].type).toBe("content");
|
||||
expect(result.canRetry).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect rebase conflicts", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock rebase test - conflicts detected
|
||||
mockGit.raw.mockRejectedValueOnce(
|
||||
new Error("CONFLICT (content): Rebase conflict in file.ts"),
|
||||
);
|
||||
|
||||
// Mock status - show conflicted files
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock rebase abort (cleanup)
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "rebase",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(1);
|
||||
expect(result.strategy).toBe("rebase");
|
||||
});
|
||||
|
||||
it("should handle fetch failure", async () => {
|
||||
// Mock fetch failure
|
||||
mockGit.fetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(
|
||||
service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
}),
|
||||
).rejects.toThrow(ConflictDetectionError);
|
||||
});
|
||||
|
||||
it("should detect delete conflicts", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - conflicts detected
|
||||
mockGit.raw.mockRejectedValueOnce(
|
||||
new Error("CONFLICT (delete/modify): file.ts deleted in HEAD"),
|
||||
);
|
||||
|
||||
// Mock status - show conflicted files with delete
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file.ts",
|
||||
index: "D",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock merge abort
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(true);
|
||||
expect(result.conflicts[0].type).toBe("delete");
|
||||
});
|
||||
|
||||
it("should detect add conflicts", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - conflicts detected
|
||||
mockGit.raw.mockRejectedValueOnce(
|
||||
new Error("CONFLICT (add/add): Merge conflict in file.ts"),
|
||||
);
|
||||
|
||||
// Mock status - show conflicted files with add
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file.ts",
|
||||
index: "A",
|
||||
working_dir: "A",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock merge abort
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(true);
|
||||
expect(result.conflicts[0].type).toBe("add");
|
||||
});
|
||||
|
||||
it("should use default values for remote and branch", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - no conflicts
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
// Mock status - no conflicted files
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo");
|
||||
|
||||
expect(result.remoteBranch).toBe("develop");
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
||||
});
|
||||
|
||||
it("should clean up after conflict detection", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - conflicts
|
||||
mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT"));
|
||||
|
||||
// Mock status
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts"],
|
||||
files: [],
|
||||
});
|
||||
|
||||
// Track raw calls
|
||||
const rawCalls: string[][] = [];
|
||||
mockGit.raw.mockImplementation((args: string[]) => {
|
||||
rawCalls.push(args);
|
||||
if (args[0] === "merge") {
|
||||
if (args[1] === "--abort") {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
return Promise.reject(new Error("CONFLICT"));
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
await service.checkForConflicts("/test/repo", {
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
// Verify abort was called
|
||||
expect(rawCalls).toContainEqual(["merge", "--abort"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRemote", () => {
|
||||
it("should fetch from remote successfully", async () => {
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
await service.fetchRemote("/test/repo", "origin", "develop");
|
||||
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
||||
});
|
||||
|
||||
it("should throw ConflictDetectionError on fetch failure", async () => {
|
||||
mockGit.fetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(
|
||||
service.fetchRemote("/test/repo", "origin", "develop"),
|
||||
).rejects.toThrow(ConflictDetectionError);
|
||||
});
|
||||
|
||||
it("should use default remote", async () => {
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
await service.fetchRemote("/test/repo");
|
||||
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith("origin", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectConflicts", () => {
|
||||
it("should return empty array when no conflicts", async () => {
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
const conflicts = await service.detectConflicts("/test/repo");
|
||||
|
||||
expect(conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should detect conflicted files", async () => {
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file1.ts", "src/file2.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file1.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
{
|
||||
path: "src/file2.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const conflicts = await service.detectConflicts("/test/repo");
|
||||
|
||||
expect(conflicts).toHaveLength(2);
|
||||
expect(conflicts[0].file).toBe("src/file1.ts");
|
||||
expect(conflicts[1].file).toBe("src/file2.ts");
|
||||
});
|
||||
|
||||
it("should determine conflict type from git status", async () => {
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["deleted.ts", "added.ts", "modified.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "deleted.ts",
|
||||
index: "D",
|
||||
working_dir: "U",
|
||||
},
|
||||
{
|
||||
path: "added.ts",
|
||||
index: "A",
|
||||
working_dir: "A",
|
||||
},
|
||||
{
|
||||
path: "modified.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const conflicts = await service.detectConflicts("/test/repo");
|
||||
|
||||
expect(conflicts[0].type).toBe("delete");
|
||||
expect(conflicts[1].type).toBe("add");
|
||||
expect(conflicts[2].type).toBe("content");
|
||||
});
|
||||
|
||||
it("should throw ConflictDetectionError on git status failure", async () => {
|
||||
mockGit.status.mockRejectedValue(new Error("Git error"));
|
||||
|
||||
await expect(service.detectConflicts("/test/repo")).rejects.toThrow(
|
||||
ConflictDetectionError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentBranch", () => {
|
||||
it("should return current branch name", async () => {
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
const branch = await service.getCurrentBranch("/test/repo");
|
||||
|
||||
expect(branch).toBe("feature-branch");
|
||||
expect(mockGit.revparse).toHaveBeenCalledWith([
|
||||
"--abbrev-ref",
|
||||
"HEAD",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw ConflictDetectionError on failure", async () => {
|
||||
mockGit.revparse.mockRejectedValue(new Error("Not a git repository"));
|
||||
|
||||
await expect(service.getCurrentBranch("/test/repo")).rejects.toThrow(
|
||||
ConflictDetectionError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user