- Build complete Next.js CRM for commercial real estate - Add authentication with JWT sessions and role-based access - Add GoHighLevel API integration for contacts, conversations, opportunities - Add AI-powered Control Center with tool calling - Add Setup page with onboarding checklist (/setup) - Add sidebar navigation with Setup menu item - Fix type errors in onboarding API, GHL services, and control center tools - Add Prisma schema with SQLite for local development - Add UI components with clay morphism design system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ToolRouter } from '@/lib/control-center/tool-router';
|
|
import { MCPClient } from '@/lib/control-center/mcp-client';
|
|
import { appToolsRegistry, appToolDefinitions } from '@/lib/control-center/app-tools';
|
|
import { ToolCall, ToolContext, ToolDefinition } from '@/lib/control-center/types';
|
|
|
|
// Mock the MCP client
|
|
vi.mock('@/lib/control-center/mcp-client');
|
|
|
|
// Mock the app tools registry
|
|
vi.mock('@/lib/control-center/app-tools', () => ({
|
|
appToolsRegistry: {
|
|
getDefinitions: vi.fn(),
|
|
has: vi.fn(),
|
|
execute: vi.fn()
|
|
},
|
|
appToolDefinitions: [
|
|
{
|
|
name: 'get_dashboard_stats',
|
|
description: 'Get dashboard stats',
|
|
inputSchema: { type: 'object', properties: {} }
|
|
},
|
|
{
|
|
name: 'list_recent_contacts',
|
|
description: 'List contacts',
|
|
inputSchema: { type: 'object', properties: {} }
|
|
}
|
|
]
|
|
}));
|
|
|
|
describe('ToolRouter', () => {
|
|
let mockMcpClient: MCPClient;
|
|
let mockContext: ToolContext;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Create mock MCP client
|
|
mockMcpClient = {
|
|
getTools: vi.fn(),
|
|
executeTool: vi.fn(),
|
|
healthCheck: vi.fn(),
|
|
clearCache: vi.fn()
|
|
} as unknown as MCPClient;
|
|
|
|
// Default context for tests
|
|
mockContext = {
|
|
userId: 'user-123',
|
|
ghlClient: null
|
|
};
|
|
|
|
// Setup default app tools mock
|
|
vi.mocked(appToolsRegistry.getDefinitions).mockReturnValue(appToolDefinitions);
|
|
vi.mocked(appToolsRegistry.has).mockReturnValue(false);
|
|
});
|
|
|
|
describe('getAllTools', () => {
|
|
it('should return combined MCP and app tools', async () => {
|
|
const mcpTools: ToolDefinition[] = [
|
|
{
|
|
name: 'ghl_get_contacts',
|
|
description: 'Get GHL contacts',
|
|
inputSchema: { type: 'object', properties: {} }
|
|
},
|
|
{
|
|
name: 'ghl_send_message',
|
|
description: 'Send GHL message',
|
|
inputSchema: { type: 'object', properties: {} }
|
|
}
|
|
];
|
|
|
|
vi.mocked(mockMcpClient.getTools).mockResolvedValue(mcpTools);
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const tools = await toolRouter.getAllTools();
|
|
|
|
expect(appToolsRegistry.getDefinitions).toHaveBeenCalled();
|
|
expect(mockMcpClient.getTools).toHaveBeenCalled();
|
|
expect(tools).toHaveLength(4); // 2 app tools + 2 MCP tools
|
|
expect(tools.map(t => t.name)).toContain('get_dashboard_stats');
|
|
expect(tools.map(t => t.name)).toContain('ghl_get_contacts');
|
|
});
|
|
|
|
it('should return only app tools when MCP client is null', async () => {
|
|
const toolRouter = new ToolRouter(null);
|
|
const tools = await toolRouter.getAllTools();
|
|
|
|
expect(appToolsRegistry.getDefinitions).toHaveBeenCalled();
|
|
expect(tools).toEqual(appToolDefinitions);
|
|
expect(tools).toHaveLength(2);
|
|
});
|
|
|
|
it('should return only app tools when MCP getTools fails', async () => {
|
|
vi.mocked(mockMcpClient.getTools).mockRejectedValue(new Error('MCP connection failed'));
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const tools = await toolRouter.getAllTools();
|
|
|
|
expect(tools).toEqual(appToolDefinitions);
|
|
expect(tools).toHaveLength(2);
|
|
});
|
|
|
|
it('should handle MCP returning empty tools array', async () => {
|
|
vi.mocked(mockMcpClient.getTools).mockResolvedValue([]);
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const tools = await toolRouter.getAllTools();
|
|
|
|
expect(tools).toEqual(appToolDefinitions);
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('should route app tool calls to appToolsRegistry', async () => {
|
|
const toolCall: ToolCall = {
|
|
id: 'tc-123',
|
|
name: 'get_dashboard_stats',
|
|
input: {}
|
|
};
|
|
|
|
vi.mocked(appToolsRegistry.has).mockReturnValue(true);
|
|
vi.mocked(appToolsRegistry.execute).mockResolvedValue({
|
|
toolCallId: '',
|
|
success: true,
|
|
result: { totalContacts: 100, totalConversations: 50 }
|
|
});
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const result = await toolRouter.execute(toolCall, mockContext);
|
|
|
|
expect(appToolsRegistry.has).toHaveBeenCalledWith('get_dashboard_stats');
|
|
expect(appToolsRegistry.execute).toHaveBeenCalledWith(
|
|
'get_dashboard_stats',
|
|
{},
|
|
mockContext
|
|
);
|
|
expect(result.toolCallId).toBe('tc-123');
|
|
expect(result.success).toBe(true);
|
|
expect(result.result).toEqual({ totalContacts: 100, totalConversations: 50 });
|
|
});
|
|
|
|
it('should route MCP tool calls to MCPClient', async () => {
|
|
const toolCall: ToolCall = {
|
|
id: 'tc-456',
|
|
name: 'ghl_get_contacts',
|
|
input: { limit: 10 }
|
|
};
|
|
|
|
vi.mocked(appToolsRegistry.has).mockReturnValue(false);
|
|
vi.mocked(mockMcpClient.executeTool).mockResolvedValue({
|
|
toolCallId: '',
|
|
success: true,
|
|
result: [{ id: 'contact-1', name: 'John Doe' }]
|
|
});
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const result = await toolRouter.execute(toolCall, mockContext);
|
|
|
|
expect(appToolsRegistry.has).toHaveBeenCalledWith('ghl_get_contacts');
|
|
expect(mockMcpClient.executeTool).toHaveBeenCalledWith('ghl_get_contacts', { limit: 10 });
|
|
expect(result.toolCallId).toBe('tc-456');
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should return error when MCP client is not configured for MCP tools', async () => {
|
|
const toolCall: ToolCall = {
|
|
id: 'tc-789',
|
|
name: 'ghl_get_contacts',
|
|
input: {}
|
|
};
|
|
|
|
vi.mocked(appToolsRegistry.has).mockReturnValue(false);
|
|
|
|
const toolRouter = new ToolRouter(null);
|
|
const result = await toolRouter.execute(toolCall, mockContext);
|
|
|
|
expect(result.toolCallId).toBe('tc-789');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('MCP client not configured');
|
|
});
|
|
|
|
it('should handle app tool execution errors', async () => {
|
|
const toolCall: ToolCall = {
|
|
id: 'tc-error',
|
|
name: 'list_recent_contacts',
|
|
input: {}
|
|
};
|
|
|
|
vi.mocked(appToolsRegistry.has).mockReturnValue(true);
|
|
vi.mocked(appToolsRegistry.execute).mockResolvedValue({
|
|
toolCallId: '',
|
|
success: false,
|
|
error: 'GHL not configured'
|
|
});
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const result = await toolRouter.execute(toolCall, mockContext);
|
|
|
|
expect(result.toolCallId).toBe('tc-error');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('GHL not configured');
|
|
});
|
|
|
|
it('should handle MCP tool execution errors', async () => {
|
|
const toolCall: ToolCall = {
|
|
id: 'tc-mcp-error',
|
|
name: 'ghl_send_message',
|
|
input: { contactId: '123', message: 'Hello' }
|
|
};
|
|
|
|
vi.mocked(appToolsRegistry.has).mockReturnValue(false);
|
|
vi.mocked(mockMcpClient.executeTool).mockResolvedValue({
|
|
toolCallId: '',
|
|
success: false,
|
|
error: 'Tool execution failed: 500 - Internal server error'
|
|
});
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const result = await toolRouter.execute(toolCall, mockContext);
|
|
|
|
expect(result.toolCallId).toBe('tc-mcp-error');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Tool execution failed');
|
|
});
|
|
});
|
|
|
|
describe('executeAll', () => {
|
|
it('should execute multiple tool calls in parallel', async () => {
|
|
const toolCalls: ToolCall[] = [
|
|
{ id: 'tc-1', name: 'get_dashboard_stats', input: {} },
|
|
{ id: 'tc-2', name: 'ghl_get_contacts', input: { limit: 5 } }
|
|
];
|
|
|
|
vi.mocked(appToolsRegistry.has).mockImplementation((name) =>
|
|
name === 'get_dashboard_stats'
|
|
);
|
|
|
|
vi.mocked(appToolsRegistry.execute).mockResolvedValue({
|
|
toolCallId: '',
|
|
success: true,
|
|
result: { totalContacts: 100 }
|
|
});
|
|
|
|
vi.mocked(mockMcpClient.executeTool).mockResolvedValue({
|
|
toolCallId: '',
|
|
success: true,
|
|
result: []
|
|
});
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const results = await toolRouter.executeAll(toolCalls, mockContext);
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].toolCallId).toBe('tc-1');
|
|
expect(results[1].toolCallId).toBe('tc-2');
|
|
});
|
|
|
|
it('should handle partial failures in executeAll', async () => {
|
|
const toolCalls: ToolCall[] = [
|
|
{ id: 'tc-1', name: 'get_dashboard_stats', input: {} },
|
|
{ id: 'tc-2', name: 'failing_tool', input: {} }
|
|
];
|
|
|
|
vi.mocked(appToolsRegistry.has).mockImplementation((name) =>
|
|
name === 'get_dashboard_stats' || name === 'failing_tool'
|
|
);
|
|
|
|
vi.mocked(appToolsRegistry.execute)
|
|
.mockResolvedValueOnce({
|
|
toolCallId: '',
|
|
success: true,
|
|
result: { totalContacts: 100 }
|
|
})
|
|
.mockResolvedValueOnce({
|
|
toolCallId: '',
|
|
success: false,
|
|
error: 'Tool failed'
|
|
});
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const results = await toolRouter.executeAll(toolCalls, mockContext);
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[1].success).toBe(false);
|
|
});
|
|
|
|
it('should return empty array for empty tool calls', async () => {
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const results = await toolRouter.executeAll([], mockContext);
|
|
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('handles MCP not configured gracefully', () => {
|
|
it('should work with null MCP client for app-only workflows', async () => {
|
|
const toolCall: ToolCall = {
|
|
id: 'tc-app',
|
|
name: 'get_dashboard_stats',
|
|
input: {}
|
|
};
|
|
|
|
vi.mocked(appToolsRegistry.has).mockReturnValue(true);
|
|
vi.mocked(appToolsRegistry.execute).mockResolvedValue({
|
|
toolCallId: '',
|
|
success: true,
|
|
result: { totalContacts: 50 }
|
|
});
|
|
|
|
const toolRouter = new ToolRouter(null);
|
|
|
|
// Get tools should work
|
|
const tools = await toolRouter.getAllTools();
|
|
expect(tools).toEqual(appToolDefinitions);
|
|
|
|
// Execute app tools should work
|
|
const result = await toolRouter.execute(toolCall, mockContext);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should log error but return app tools when MCP fails', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
vi.mocked(mockMcpClient.getTools).mockRejectedValue(
|
|
new Error('Connection refused')
|
|
);
|
|
|
|
const toolRouter = new ToolRouter(mockMcpClient);
|
|
const tools = await toolRouter.getAllTools();
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
'Failed to get MCP tools:',
|
|
expect.any(Error)
|
|
);
|
|
expect(tools).toEqual(appToolDefinitions);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|