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:
411
apps/orchestrator/src/valkey/valkey.client.spec.ts
Normal file
411
apps/orchestrator/src/valkey/valkey.client.spec.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { ValkeyClient } from './valkey.client';
|
||||
import type { TaskState, AgentState, OrchestratorEvent } from './types';
|
||||
|
||||
// Create a shared mock instance that will be used across all tests
|
||||
const mockRedisInstance = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
keys: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock ioredis
|
||||
vi.mock('ioredis', () => {
|
||||
return {
|
||||
default: class {
|
||||
constructor() {
|
||||
return mockRedisInstance;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ValkeyClient', () => {
|
||||
let client: ValkeyClient;
|
||||
let mockRedis: typeof mockRedisInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create client instance
|
||||
client = new ValkeyClient({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
});
|
||||
|
||||
// Reference the mock instance
|
||||
mockRedis = mockRedisInstance;
|
||||
|
||||
// Mock duplicate to return another mock client
|
||||
mockRedis.duplicate.mockReturnValue(mockRedis);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Connection Management', () => {
|
||||
it('should disconnect on close', async () => {
|
||||
mockRedis.quit.mockResolvedValue('OK');
|
||||
|
||||
await client.disconnect();
|
||||
|
||||
expect(mockRedis.quit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disconnect subscriber if it exists', async () => {
|
||||
mockRedis.quit.mockResolvedValue('OK');
|
||||
mockRedis.subscribe.mockResolvedValue(1);
|
||||
|
||||
// Create subscriber
|
||||
await client.subscribeToEvents(vi.fn());
|
||||
|
||||
await client.disconnect();
|
||||
|
||||
// Should call quit twice (main client and subscriber)
|
||||
expect(mockRedis.quit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(mockTaskState));
|
||||
|
||||
const result = await client.getTaskState('task-123');
|
||||
|
||||
expect(mockRedis.get).toHaveBeenCalledWith('orchestrator:task:task-123');
|
||||
expect(result).toEqual(mockTaskState);
|
||||
});
|
||||
|
||||
it('should return null for non-existent task', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
|
||||
const result = await client.getTaskState('task-999');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should set task state', async () => {
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
await client.setTaskState(mockTaskState);
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
'orchestrator:task:task-123',
|
||||
JSON.stringify(mockTaskState)
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete task state', async () => {
|
||||
mockRedis.del.mockResolvedValue(1);
|
||||
|
||||
await client.deleteTaskState('task-123');
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('orchestrator:task:task-123');
|
||||
});
|
||||
|
||||
it('should update task status', async () => {
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(mockTaskState));
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
const result = await client.updateTaskStatus('task-123', 'assigned', 'agent-456');
|
||||
|
||||
expect(mockRedis.get).toHaveBeenCalledWith('orchestrator:task:task-123');
|
||||
expect(mockRedis.set).toHaveBeenCalled();
|
||||
expect(result?.status).toBe('assigned');
|
||||
expect(result?.agentId).toBe('agent-456');
|
||||
expect(result?.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent task', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
|
||||
await expect(client.updateTaskStatus('task-999', 'assigned')).rejects.toThrow(
|
||||
'Task task-999 not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid task status transition', async () => {
|
||||
const completedTask = { ...mockTaskState, status: 'completed' as const };
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(completedTask));
|
||||
|
||||
await expect(client.updateTaskStatus('task-123', 'assigned')).rejects.toThrow(
|
||||
'Invalid task state transition from completed to assigned'
|
||||
);
|
||||
});
|
||||
|
||||
it('should list all task states', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['orchestrator:task:task-1', 'orchestrator:task:task-2']);
|
||||
mockRedis.get
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...mockTaskState, taskId: 'task-1' }))
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...mockTaskState, taskId: 'task-2' }));
|
||||
|
||||
const result = await client.listTasks();
|
||||
|
||||
expect(mockRedis.keys).toHaveBeenCalledWith('orchestrator:task:*');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].taskId).toBe('task-1');
|
||||
expect(result[1].taskId).toBe('task-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent State Management', () => {
|
||||
const mockAgentState: AgentState = {
|
||||
agentId: 'agent-456',
|
||||
status: 'spawning',
|
||||
taskId: 'task-123',
|
||||
};
|
||||
|
||||
it('should get agent state', async () => {
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(mockAgentState));
|
||||
|
||||
const result = await client.getAgentState('agent-456');
|
||||
|
||||
expect(mockRedis.get).toHaveBeenCalledWith('orchestrator:agent:agent-456');
|
||||
expect(result).toEqual(mockAgentState);
|
||||
});
|
||||
|
||||
it('should return null for non-existent agent', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
|
||||
const result = await client.getAgentState('agent-999');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should set agent state', async () => {
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
await client.setAgentState(mockAgentState);
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
'orchestrator:agent:agent-456',
|
||||
JSON.stringify(mockAgentState)
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete agent state', async () => {
|
||||
mockRedis.del.mockResolvedValue(1);
|
||||
|
||||
await client.deleteAgentState('agent-456');
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith('orchestrator:agent:agent-456');
|
||||
});
|
||||
|
||||
it('should update agent status', async () => {
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(mockAgentState));
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
const result = await client.updateAgentStatus('agent-456', 'running');
|
||||
|
||||
expect(mockRedis.get).toHaveBeenCalledWith('orchestrator:agent:agent-456');
|
||||
expect(mockRedis.set).toHaveBeenCalled();
|
||||
expect(result?.status).toBe('running');
|
||||
expect(result?.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set completedAt when status is completed', async () => {
|
||||
const runningAgent = { ...mockAgentState, status: 'running' as const };
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(runningAgent));
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
const result = await client.updateAgentStatus('agent-456', 'completed');
|
||||
|
||||
expect(result?.status).toBe('completed');
|
||||
expect(result?.completedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent agent', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
|
||||
await expect(client.updateAgentStatus('agent-999', 'running')).rejects.toThrow(
|
||||
'Agent agent-999 not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid agent status transition', async () => {
|
||||
const completedAgent = { ...mockAgentState, status: 'completed' as const };
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(completedAgent));
|
||||
|
||||
await expect(client.updateAgentStatus('agent-456', 'running')).rejects.toThrow(
|
||||
'Invalid agent state transition from completed to running'
|
||||
);
|
||||
});
|
||||
|
||||
it('should list all agent states', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['orchestrator:agent:agent-1', 'orchestrator:agent:agent-2']);
|
||||
mockRedis.get
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...mockAgentState, agentId: 'agent-1' }))
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...mockAgentState, agentId: 'agent-2' }));
|
||||
|
||||
const result = await client.listAgents();
|
||||
|
||||
expect(mockRedis.keys).toHaveBeenCalledWith('orchestrator:agent:*');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].agentId).toBe('agent-1');
|
||||
expect(result[1].agentId).toBe('agent-2');
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
mockRedis.publish.mockResolvedValue(1);
|
||||
|
||||
await client.publishEvent(mockEvent);
|
||||
|
||||
expect(mockRedis.publish).toHaveBeenCalledWith(
|
||||
'orchestrator:events',
|
||||
JSON.stringify(mockEvent)
|
||||
);
|
||||
});
|
||||
|
||||
it('should subscribe to events', async () => {
|
||||
mockRedis.subscribe.mockResolvedValue(1);
|
||||
|
||||
const handler = vi.fn();
|
||||
await client.subscribeToEvents(handler);
|
||||
|
||||
expect(mockRedis.duplicate).toHaveBeenCalled();
|
||||
expect(mockRedis.subscribe).toHaveBeenCalledWith('orchestrator:events');
|
||||
});
|
||||
|
||||
it('should call handler when event is received', async () => {
|
||||
mockRedis.subscribe.mockResolvedValue(1);
|
||||
let messageHandler: ((channel: string, message: string) => void) | undefined;
|
||||
|
||||
mockRedis.on.mockImplementation((event: string, handler: (channel: string, message: string) => void) => {
|
||||
if (event === 'message') {
|
||||
messageHandler = handler;
|
||||
}
|
||||
return mockRedis;
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
await client.subscribeToEvents(handler);
|
||||
|
||||
// Simulate receiving a message
|
||||
if (messageHandler) {
|
||||
messageHandler('orchestrator:events', JSON.stringify(mockEvent));
|
||||
}
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(mockEvent);
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in events gracefully', async () => {
|
||||
mockRedis.subscribe.mockResolvedValue(1);
|
||||
let messageHandler: ((channel: string, message: string) => void) | undefined;
|
||||
|
||||
mockRedis.on.mockImplementation((event: string, handler: (channel: string, message: string) => void) => {
|
||||
if (event === 'message') {
|
||||
messageHandler = handler;
|
||||
}
|
||||
return mockRedis;
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await client.subscribeToEvents(handler);
|
||||
|
||||
// Simulate receiving invalid JSON
|
||||
if (messageHandler) {
|
||||
messageHandler('orchestrator:events', 'invalid json');
|
||||
}
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle task updates with error parameter', async () => {
|
||||
const taskState: 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',
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(taskState));
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
const result = await client.updateTaskStatus('task-123', 'failed', undefined, 'Test error');
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.metadata?.error).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should handle agent updates with error parameter', async () => {
|
||||
const agentState: AgentState = {
|
||||
agentId: 'agent-456',
|
||||
status: 'running',
|
||||
taskId: 'task-123',
|
||||
};
|
||||
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(agentState));
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
|
||||
const result = await client.updateAgentStatus('agent-456', 'failed', 'Test error');
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should filter out null values in listTasks', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['orchestrator:task:task-1', 'orchestrator:task:task-2']);
|
||||
mockRedis.get
|
||||
.mockResolvedValueOnce(JSON.stringify({ taskId: 'task-1', status: 'pending' }))
|
||||
.mockResolvedValueOnce(null); // Simulate deleted task
|
||||
|
||||
const result = await client.listTasks();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].taskId).toBe('task-1');
|
||||
});
|
||||
|
||||
it('should filter out null values in listAgents', async () => {
|
||||
mockRedis.keys.mockResolvedValue(['orchestrator:agent:agent-1', 'orchestrator:agent:agent-2']);
|
||||
mockRedis.get
|
||||
.mockResolvedValueOnce(JSON.stringify({ agentId: 'agent-1', status: 'running' }))
|
||||
.mockResolvedValueOnce(null); // Simulate deleted agent
|
||||
|
||||
const result = await client.listAgents();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].agentId).toBe('agent-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user