/**
* mysql-mcp - Cluster Tools Unit Tests
*
* Tests for cluster tool definitions, annotations, and handler execution.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getClusterTools } from '../cluster/index.js';
import type { MySQLAdapter } from '../../MySQLAdapter.js';
import { createMockMySQLAdapter, createMockRequestContext, createMockQueryResult } from '../../../../__tests__/mocks/index.js';
describe('getClusterTools', () => {
let tools: ReturnType<typeof getClusterTools>;
beforeEach(() => {
vi.clearAllMocks();
tools = getClusterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
});
it('should return 10 cluster tools', () => {
expect(tools).toHaveLength(10);
});
it('should have cluster group for all tools', () => {
for (const tool of tools) {
expect(tool.group).toBe('cluster');
}
});
it('should have handler functions for all tools', () => {
for (const tool of tools) {
expect(typeof tool.handler).toBe('function');
}
});
it('should have inputSchema for all tools', () => {
for (const tool of tools) {
expect(tool.inputSchema).toBeDefined();
}
});
it('should include expected tool names', () => {
const names = tools.map(t => t.name);
expect(names).toContain('mysql_gr_status');
expect(names).toContain('mysql_gr_members');
expect(names).toContain('mysql_gr_primary');
expect(names).toContain('mysql_gr_transactions');
expect(names).toContain('mysql_gr_flow_control');
expect(names).toContain('mysql_cluster_status');
expect(names).toContain('mysql_cluster_instances');
expect(names).toContain('mysql_cluster_topology');
expect(names).toContain('mysql_cluster_router_status');
expect(names).toContain('mysql_cluster_switchover');
});
});
describe('Handler Execution', () => {
let mockAdapter: ReturnType<typeof createMockMySQLAdapter>;
let tools: ReturnType<typeof getClusterTools>;
let mockContext: ReturnType<typeof createMockRequestContext>;
beforeEach(() => {
vi.clearAllMocks();
mockAdapter = createMockMySQLAdapter();
tools = getClusterTools(mockAdapter as unknown as MySQLAdapter);
mockContext = createMockRequestContext();
});
describe('mysql_gr_status', () => {
it('should query group_replication status', async () => {
mockAdapter.executeQuery
.mockResolvedValueOnce(createMockQueryResult([{ PLUGIN_STATUS: 'ACTIVE' }])) // Plugin check
.mockResolvedValueOnce(
createMockQueryResult([{ MEMBER_STATE: 'ONLINE' }])
);
const tool = tools.find(t => t.name === 'mysql_gr_status')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
// Returns enabled, groupName, members etc
expect(result).toHaveProperty('enabled');
expect(result).toHaveProperty('members');
});
});
describe('mysql_gr_members', () => {
it('should list group replication members', async () => {
mockAdapter.executeQuery
.mockResolvedValueOnce(createMockQueryResult([{ PLUGIN_STATUS: 'ACTIVE' }])) // Plugin check
.mockResolvedValueOnce(
createMockQueryResult([
{ memberId: 'uuid1', host: 'node1', state: 'ONLINE' },
{ memberId: 'uuid2', host: 'node2', state: 'ONLINE' }
])
);
const tool = tools.find(t => t.name === 'mysql_gr_members')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
const call = mockAdapter.executeQuery.mock.calls[1][0] as string; // Second call is the query
expect(call).toContain('replication_group_members');
expect(result).toHaveProperty('members');
expect(result).toHaveProperty('count');
});
it('should accept memberId parameter', async () => {
mockAdapter.executeQuery
.mockResolvedValueOnce(createMockQueryResult([{ PLUGIN_STATUS: 'ACTIVE' }])) // Plugin check
.mockResolvedValueOnce(createMockQueryResult([]));
const tool = tools.find(t => t.name === 'mysql_gr_members')!;
await tool.handler({ memberId: 'uuid1' }, mockContext);
// Plugin check is first call
expect(mockAdapter.executeQuery).toHaveBeenNthCalledWith(1,
expect.stringContaining('SELECT PLUGIN_STATUS FROM information_schema.PLUGINS')
);
// Uses parameterized query with ?
expect(mockAdapter.executeQuery).toHaveBeenNthCalledWith(2,
expect.stringContaining('WHERE m.MEMBER_ID = ?'),
['uuid1']
);
});
});
describe('mysql_gr_primary', () => {
it('should get primary member info', async () => {
mockAdapter.executeQuery
.mockResolvedValueOnce(createMockQueryResult([{ PLUGIN_STATUS: 'ACTIVE' }])) // Plugin check
.mockResolvedValueOnce(
createMockQueryResult([{ memberId: 'uuid1', host: 'primary.local' }])
);
const tool = tools.find(t => t.name === 'mysql_gr_primary')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
expect(result).toHaveProperty('primary');
expect(result).toHaveProperty('hasPrimary');
});
});
describe('mysql_gr_transactions', () => {
it('should get transaction status', async () => {
mockAdapter.executeQuery
.mockResolvedValueOnce(createMockQueryResult([{ PLUGIN_STATUS: 'ACTIVE' }])) // Plugin check
.mockResolvedValueOnce(createMockQueryResult([
{ memberId: 'uuid1', txInQueue: 0, txChecked: 100 }
]));
const tool = tools.find(t => t.name === 'mysql_gr_transactions')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
expect(result).toHaveProperty('memberStats');
expect(result).toHaveProperty('gtid');
});
});
describe('mysql_gr_flow_control', () => {
it('should get flow control statistics', async () => {
mockAdapter.executeQuery
.mockResolvedValueOnce(createMockQueryResult([{ PLUGIN_STATUS: 'ACTIVE' }])) // Plugin check
.mockResolvedValueOnce(createMockQueryResult([
{ flowControlMode: 'QUOTA', certifierThreshold: 25000 }
]));
const tool = tools.find(t => t.name === 'mysql_gr_flow_control')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
expect(result).toHaveProperty('configuration');
expect(result).toHaveProperty('memberQueues');
expect(result).toHaveProperty('isThrottling');
});
});
describe('mysql_cluster_status', () => {
it('should get cluster status', async () => {
mockAdapter.executeQuery.mockResolvedValue(createMockQueryResult([
{ cluster_name: 'myCluster', status: 'OK' }
]));
const tool = tools.find(t => t.name === 'mysql_cluster_status')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
// Returns isInnoDBCluster, cluster, etc
expect(result).toBeDefined();
});
});
describe('mysql_cluster_instances', () => {
it('should list cluster instances', async () => {
mockAdapter.executeQuery.mockResolvedValue(createMockQueryResult([
{ instance_name: 'mysql-1', address: 'mysql-1:3306' }
]));
const tool = tools.find(t => t.name === 'mysql_cluster_instances')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
expect(result).toHaveProperty('instances');
});
it('should fallback to GR members if metadata query fails', async () => {
// First query (metadata) fails
mockAdapter.executeQuery.mockRejectedValueOnce(new Error('Table not found'));
// Second query (GR members) succeeds
mockAdapter.executeQuery.mockResolvedValueOnce(createMockQueryResult([
{ serverUuid: 'uuid1', address: 'node1:3306', memberState: 'ONLINE' }
]));
const tool = tools.find(t => t.name === 'mysql_cluster_instances')!;
const result = await tool.handler({}, mockContext);
expect(result).toHaveProperty('source', 'group_replication');
expect(result).toHaveProperty('instances');
// Two calls: 1. metadata query, 2. fallback query
expect(mockAdapter.executeQuery).toHaveBeenCalledTimes(2);
});
});
describe('mysql_cluster_topology', () => {
it('should get cluster topology', async () => {
mockAdapter.executeQuery.mockResolvedValue(createMockQueryResult([
{ id: 'uuid1', host: 'node1', role: 'PRIMARY', state: 'ONLINE' }
]));
const tool = tools.find(t => t.name === 'mysql_cluster_topology')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
expect(result).toHaveProperty('topology');
expect(result).toHaveProperty('visualization');
});
it('should visualize all member states correctly', async () => {
mockAdapter.executeQuery.mockResolvedValue(createMockQueryResult([
{ id: 'uuid1', host: 'node1', role: 'PRIMARY', state: 'ONLINE', port: 3306 },
{ id: 'uuid2', host: 'node2', role: 'SECONDARY', state: 'ONLINE', port: 3306 },
{ id: 'uuid3', host: 'node3', role: 'SECONDARY', state: 'RECOVERING', port: 3306 },
{ id: 'uuid4', host: 'node4', role: 'SECONDARY', state: 'OFFLINE', port: 3306 }
]));
const tool = tools.find(t => t.name === 'mysql_cluster_topology')!;
const result: any = await tool.handler({}, mockContext);
const viz = result.visualization;
expect(viz).toContain('PRIMARY:');
expect(viz).toContain('SECONDARY:');
expect(viz).toContain('RECOVERING:');
expect(viz).toContain('OFFLINE/ERROR:');
expect(viz).toContain('★ node1:3306 (ONLINE)');
expect(viz).toContain('○ node2:3306 (ONLINE)');
expect(viz).toContain('⟳ node3:3306');
expect(viz).toContain('✗ node4:3306 (OFFLINE)');
});
});
describe('mysql_cluster_router_status', () => {
it('should get router status from cluster perspective', async () => {
mockAdapter.executeQuery.mockResolvedValue(createMockQueryResult([
{ routerName: 'router1', lastCheckIn: '2024-01-01' }
]));
const tool = tools.find(t => t.name === 'mysql_cluster_router_status')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should handle error when router metadata is missing', async () => {
mockAdapter.executeQuery.mockRejectedValue(new Error('Table not found'));
const tool = tools.find(t => t.name === 'mysql_cluster_router_status')!;
const result: any = await tool.handler({}, mockContext);
expect(result.available).toBe(false);
expect(result.message).toContain('Router metadata not available');
expect(result.suggestion).toBeDefined();
});
});
describe('mysql_cluster_switchover', () => {
it('should get switchover recommendation', async () => {
mockAdapter.executeQuery.mockResolvedValue(createMockQueryResult([
{ memberId: 'uuid1', host: 'node1', role: 'SECONDARY', state: 'ONLINE' }
]));
const tool = tools.find(t => t.name === 'mysql_cluster_switchover')!;
const result = await tool.handler({}, mockContext);
expect(mockAdapter.executeQuery).toHaveBeenCalled();
expect(result).toHaveProperty('candidates');
expect(result).toHaveProperty('canSwitchover');
});
it('should categorize candidates by lag suitability', async () => {
// Mock members with different queue sizes
// uuid1: 0 queue (GOOD)
// uuid2: 50 queue (ACCEPTABLE)
// uuid3: 200 queue (NOT_RECOMMENDED)
mockAdapter.executeQuery.mockResolvedValue(createMockQueryResult([
{ memberId: 'uuid1', host: 'node1', role: 'SECONDARY', state: 'ONLINE', txQueue: 0, applierQueue: 0 },
{ memberId: 'uuid2', host: 'node2', role: 'SECONDARY', state: 'ONLINE', txQueue: 20, applierQueue: 30 },
{ memberId: 'uuid3', host: 'node3', role: 'SECONDARY', state: 'ONLINE', txQueue: 150, applierQueue: 50 },
{ memberId: 'uuid4', host: 'node4', role: 'PRIMARY', state: 'ONLINE', txQueue: 0, applierQueue: 0 } // Should be ignored
]));
const tool = tools.find(t => t.name === 'mysql_cluster_switchover')!;
const result: any = await tool.handler({}, mockContext);
const candidates = result.candidates;
// Should filter out PRIMARY, so 3 candidates
expect(candidates).toHaveLength(3);
// Sort order check: GOOD -> ACCEPTABLE -> NOT_RECOMMENDED
expect(candidates[0].memberId).toBe('uuid1');
expect(candidates[0].suitability).toBe('GOOD');
expect(candidates[1].memberId).toBe('uuid2');
expect(candidates[1].suitability).toBe('ACCEPTABLE');
expect(candidates[2].memberId).toBe('uuid3');
expect(candidates[2].suitability).toBe('NOT_RECOMMENDED');
expect(result.recommendedTarget.memberId).toBe('uuid1');
expect(result.canSwitchover).toBe(true);
});
it('should warn if all candidates are not recommended', async () => {
mockAdapter.executeQuery.mockResolvedValue(createMockQueryResult([
{ memberId: 'uuid1', host: 'node1', role: 'SECONDARY', state: 'ONLINE', txQueue: 200, applierQueue: 0 }
]));
const tool = tools.find(t => t.name === 'mysql_cluster_switchover')!;
const result: any = await tool.handler({}, mockContext);
expect(result.recommendedTarget).toBeNull();
expect(result.warning).toBeDefined();
});
});
});