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 <[email protected]>
276 lines
7.9 KiB
TypeScript
276 lines
7.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { ValkeyService } from './valkey.service';
|
|
import type { TaskState, AgentState, OrchestratorEvent } from './types';
|
|
|
|
// Create mock client methods that will be shared
|
|
const mockClient = {
|
|
getTaskState: vi.fn(),
|
|
setTaskState: vi.fn(),
|
|
deleteTaskState: vi.fn(),
|
|
updateTaskStatus: vi.fn(),
|
|
listTasks: vi.fn(),
|
|
getAgentState: vi.fn(),
|
|
setAgentState: vi.fn(),
|
|
deleteAgentState: vi.fn(),
|
|
updateAgentStatus: vi.fn(),
|
|
listAgents: vi.fn(),
|
|
publishEvent: vi.fn(),
|
|
subscribeToEvents: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
};
|
|
|
|
// Mock ValkeyClient before importing
|
|
vi.mock('./valkey.client', () => {
|
|
return {
|
|
ValkeyClient: class {
|
|
constructor() {
|
|
return mockClient;
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
describe('ValkeyService', () => {
|
|
let service: ValkeyService;
|
|
let mockConfigService: ConfigService;
|
|
|
|
beforeEach(() => {
|
|
// Clear all mock calls
|
|
vi.clearAllMocks();
|
|
|
|
// Create mock config service
|
|
mockConfigService = {
|
|
get: vi.fn((key: string, defaultValue?: unknown) => {
|
|
const config: Record<string, unknown> = {
|
|
'orchestrator.valkey.host': 'localhost',
|
|
'orchestrator.valkey.port': 6379,
|
|
};
|
|
return config[key] ?? defaultValue;
|
|
}),
|
|
} as any;
|
|
|
|
// Create service directly
|
|
service = new ValkeyService(mockConfigService);
|
|
});
|
|
|
|
describe('Initialization', () => {
|
|
it('should be defined', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
it('should create ValkeyClient with config from ConfigService', () => {
|
|
expect(mockConfigService.get).toHaveBeenCalledWith('orchestrator.valkey.host', 'localhost');
|
|
expect(mockConfigService.get).toHaveBeenCalledWith('orchestrator.valkey.port', 6379);
|
|
});
|
|
|
|
it('should use password from config if provided', () => {
|
|
const configWithPassword = {
|
|
get: vi.fn((key: string, defaultValue?: unknown) => {
|
|
const config: Record<string, unknown> = {
|
|
'orchestrator.valkey.host': 'localhost',
|
|
'orchestrator.valkey.port': 6379,
|
|
'orchestrator.valkey.password': 'secret',
|
|
};
|
|
return config[key] ?? defaultValue;
|
|
}),
|
|
} as any;
|
|
|
|
const serviceWithPassword = new ValkeyService(configWithPassword);
|
|
|
|
expect(configWithPassword.get).toHaveBeenCalledWith('orchestrator.valkey.password');
|
|
});
|
|
});
|
|
|
|
describe('Lifecycle', () => {
|
|
it('should disconnect on module destroy', async () => {
|
|
mockClient.disconnect.mockResolvedValue(undefined);
|
|
|
|
await service.onModuleDestroy();
|
|
|
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Task State Management', () => {
|
|
const mockTaskState: TaskState = {
|
|
taskId: 'task-123',
|
|
status: 'pending',
|
|
context: {
|
|
repository: 'https://github.com/example/repo',
|
|
branch: 'main',
|
|
workItems: ['item-1'],
|
|
},
|
|
createdAt: '2026-02-02T10:00:00Z',
|
|
updatedAt: '2026-02-02T10:00:00Z',
|
|
};
|
|
|
|
it('should get task state', async () => {
|
|
mockClient.getTaskState.mockResolvedValue(mockTaskState);
|
|
|
|
const result = await service.getTaskState('task-123');
|
|
|
|
expect(mockClient.getTaskState).toHaveBeenCalledWith('task-123');
|
|
expect(result).toEqual(mockTaskState);
|
|
});
|
|
|
|
it('should set task state', async () => {
|
|
mockClient.setTaskState.mockResolvedValue(undefined);
|
|
|
|
await service.setTaskState(mockTaskState);
|
|
|
|
expect(mockClient.setTaskState).toHaveBeenCalledWith(mockTaskState);
|
|
});
|
|
|
|
it('should delete task state', async () => {
|
|
mockClient.deleteTaskState.mockResolvedValue(undefined);
|
|
|
|
await service.deleteTaskState('task-123');
|
|
|
|
expect(mockClient.deleteTaskState).toHaveBeenCalledWith('task-123');
|
|
});
|
|
|
|
it('should update task status', async () => {
|
|
const updatedTask = { ...mockTaskState, status: 'assigned' as const };
|
|
mockClient.updateTaskStatus.mockResolvedValue(updatedTask);
|
|
|
|
const result = await service.updateTaskStatus('task-123', 'assigned', 'agent-456');
|
|
|
|
expect(mockClient.updateTaskStatus).toHaveBeenCalledWith(
|
|
'task-123',
|
|
'assigned',
|
|
'agent-456',
|
|
undefined
|
|
);
|
|
expect(result).toEqual(updatedTask);
|
|
});
|
|
|
|
it('should list all tasks', async () => {
|
|
const tasks = [mockTaskState];
|
|
mockClient.listTasks.mockResolvedValue(tasks);
|
|
|
|
const result = await service.listTasks();
|
|
|
|
expect(mockClient.listTasks).toHaveBeenCalled();
|
|
expect(result).toEqual(tasks);
|
|
});
|
|
});
|
|
|
|
describe('Agent State Management', () => {
|
|
const mockAgentState: AgentState = {
|
|
agentId: 'agent-456',
|
|
status: 'spawning',
|
|
taskId: 'task-123',
|
|
};
|
|
|
|
it('should get agent state', async () => {
|
|
mockClient.getAgentState.mockResolvedValue(mockAgentState);
|
|
|
|
const result = await service.getAgentState('agent-456');
|
|
|
|
expect(mockClient.getAgentState).toHaveBeenCalledWith('agent-456');
|
|
expect(result).toEqual(mockAgentState);
|
|
});
|
|
|
|
it('should set agent state', async () => {
|
|
mockClient.setAgentState.mockResolvedValue(undefined);
|
|
|
|
await service.setAgentState(mockAgentState);
|
|
|
|
expect(mockClient.setAgentState).toHaveBeenCalledWith(mockAgentState);
|
|
});
|
|
|
|
it('should delete agent state', async () => {
|
|
mockClient.deleteAgentState.mockResolvedValue(undefined);
|
|
|
|
await service.deleteAgentState('agent-456');
|
|
|
|
expect(mockClient.deleteAgentState).toHaveBeenCalledWith('agent-456');
|
|
});
|
|
|
|
it('should update agent status', async () => {
|
|
const updatedAgent = { ...mockAgentState, status: 'running' as const };
|
|
mockClient.updateAgentStatus.mockResolvedValue(updatedAgent);
|
|
|
|
const result = await service.updateAgentStatus('agent-456', 'running');
|
|
|
|
expect(mockClient.updateAgentStatus).toHaveBeenCalledWith(
|
|
'agent-456',
|
|
'running',
|
|
undefined
|
|
);
|
|
expect(result).toEqual(updatedAgent);
|
|
});
|
|
|
|
it('should list all agents', async () => {
|
|
const agents = [mockAgentState];
|
|
mockClient.listAgents.mockResolvedValue(agents);
|
|
|
|
const result = await service.listAgents();
|
|
|
|
expect(mockClient.listAgents).toHaveBeenCalled();
|
|
expect(result).toEqual(agents);
|
|
});
|
|
});
|
|
|
|
describe('Event Pub/Sub', () => {
|
|
const mockEvent: OrchestratorEvent = {
|
|
type: 'agent.spawned',
|
|
agentId: 'agent-456',
|
|
taskId: 'task-123',
|
|
timestamp: '2026-02-02T10:00:00Z',
|
|
};
|
|
|
|
it('should publish events', async () => {
|
|
mockClient.publishEvent.mockResolvedValue(undefined);
|
|
|
|
await service.publishEvent(mockEvent);
|
|
|
|
expect(mockClient.publishEvent).toHaveBeenCalledWith(mockEvent);
|
|
});
|
|
|
|
it('should subscribe to events', async () => {
|
|
mockClient.subscribeToEvents.mockResolvedValue(undefined);
|
|
|
|
const handler = vi.fn();
|
|
await service.subscribeToEvents(handler);
|
|
|
|
expect(mockClient.subscribeToEvents).toHaveBeenCalledWith(handler);
|
|
});
|
|
});
|
|
|
|
describe('Convenience Methods', () => {
|
|
it('should create task state with timestamps', async () => {
|
|
mockClient.setTaskState.mockResolvedValue(undefined);
|
|
|
|
const context = {
|
|
repository: 'https://github.com/example/repo',
|
|
branch: 'main',
|
|
workItems: ['item-1'],
|
|
};
|
|
|
|
await service.createTask('task-123', context);
|
|
|
|
expect(mockClient.setTaskState).toHaveBeenCalledWith({
|
|
taskId: 'task-123',
|
|
status: 'pending',
|
|
context,
|
|
createdAt: expect.any(String),
|
|
updatedAt: expect.any(String),
|
|
});
|
|
});
|
|
|
|
it('should create agent state', async () => {
|
|
mockClient.setAgentState.mockResolvedValue(undefined);
|
|
|
|
await service.createAgent('agent-456', 'task-123');
|
|
|
|
expect(mockClient.setAgentState).toHaveBeenCalledWith({
|
|
agentId: 'agent-456',
|
|
status: 'spawning',
|
|
taskId: 'task-123',
|
|
});
|
|
});
|
|
});
|
|
});
|