/**
* mysql-mcp - DatabaseAdapter Unit Tests
*
* Tests for the abstract DatabaseAdapter base class methods.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DatabaseAdapter } from '../DatabaseAdapter.js';
import type { DatabaseConfig, QueryResult, HealthStatus, SchemaInfo, TableInfo, AdapterCapabilities, ToolDefinition, ResourceDefinition, PromptDefinition, ToolGroup } from '../../types/index.js';
// Create a concrete implementation for testing
class TestAdapter extends DatabaseAdapter {
readonly type = 'mysql' as const;
readonly name = 'Test Adapter';
readonly version = '1.0.0';
private mockQueryResult: QueryResult = { rows: [], rowsAffected: 0 };
private mockHealth: HealthStatus = { connected: true, latencyMs: 5 };
async connect(_config: DatabaseConfig): Promise<void> {
this.connected = true;
}
async disconnect(): Promise<void> {
this.connected = false;
}
async getHealth(): Promise<HealthStatus> {
return this.mockHealth;
}
async executeReadQuery(_sql: string, _params?: unknown[]): Promise<QueryResult> {
return this.mockQueryResult;
}
async executeWriteQuery(_sql: string, _params?: unknown[]): Promise<QueryResult> {
return this.mockQueryResult;
}
async executeQuery(_sql: string, _params?: unknown[]): Promise<QueryResult> {
return this.mockQueryResult;
}
async getSchema(): Promise<SchemaInfo> {
return { tables: [], views: [], indexes: [] };
}
async listTables(): Promise<TableInfo[]> {
return [];
}
async describeTable(_tableName: string): Promise<TableInfo> {
return { name: 'test', type: 'table', columns: [] };
}
async listSchemas(): Promise<string[]> {
return ['test'];
}
getCapabilities(): AdapterCapabilities {
return {
json: true,
fullTextSearch: true,
vector: false,
geospatial: true,
transactions: true,
preparedStatements: true,
connectionPooling: true,
partitioning: true,
replication: true
};
}
getSupportedToolGroups(): ToolGroup[] {
return ['core', 'transactions'];
}
getToolDefinitions(): ToolDefinition[] {
return [
{
name: 'test_tool',
title: 'Test Tool',
description: 'A test tool',
group: 'core',
inputSchema: {},
handler: async () => ({ result: 'ok' })
}
];
}
getResourceDefinitions(): ResourceDefinition[] {
return [
{
uri: 'mysql://test',
name: 'Test Resource',
description: 'A test resource',
handler: async () => ({ data: 'test' })
}
];
}
getPromptDefinitions(): PromptDefinition[] {
return [
{
name: 'test_prompt',
description: 'A test prompt',
handler: async () => 'Test prompt output'
}
];
}
setQueryResult(result: QueryResult): void {
this.mockQueryResult = result;
}
}
describe('DatabaseAdapter', () => {
let adapter: TestAdapter;
beforeEach(() => {
adapter = new TestAdapter();
});
describe('connection state', () => {
it('should start disconnected', () => {
expect(adapter.isConnected()).toBe(false);
});
it('should be connected after connect()', async () => {
await adapter.connect({ type: 'mysql', host: 'localhost', port: 3306, database: 'test', username: 'root', password: '' });
expect(adapter.isConnected()).toBe(true);
});
it('should be disconnected after disconnect()', async () => {
await adapter.connect({ type: 'mysql', host: 'localhost', port: 3306, database: 'test', username: 'root', password: '' });
await adapter.disconnect();
expect(adapter.isConnected()).toBe(false);
});
});
describe('adapter info', () => {
it('should return correct type', () => {
expect(adapter.type).toBe('mysql');
});
it('should return correct name', () => {
expect(adapter.name).toBe('Test Adapter');
});
it('should return correct version', () => {
expect(adapter.version).toBe('1.0.0');
});
it('should return adapter info object', () => {
const info = adapter.getInfo();
expect(info['type']).toBe('mysql');
expect(info['name']).toBe('Test Adapter');
expect(info['version']).toBe('1.0.0');
expect(info['connected']).toBe(false);
});
});
describe('capabilities', () => {
it('should return capabilities', () => {
const caps = adapter.getCapabilities();
expect(caps.json).toBe(true);
expect(caps.fullTextSearch).toBe(true);
expect(caps.transactions).toBe(true);
});
it('should return supported tool groups', () => {
const groups = adapter.getSupportedToolGroups();
expect(groups).toContain('core');
expect(groups).toContain('transactions');
});
});
describe('definitions', () => {
it('should return tool definitions', () => {
const tools = adapter.getToolDefinitions();
expect(tools.length).toBeGreaterThan(0);
expect(tools[0]?.name).toBe('test_tool');
});
it('should return resource definitions', () => {
const resources = adapter.getResourceDefinitions();
expect(resources.length).toBeGreaterThan(0);
expect(resources[0]?.uri).toBe('mysql://test');
});
it('should return prompt definitions', () => {
const prompts = adapter.getPromptDefinitions();
expect(prompts.length).toBeGreaterThan(0);
expect(prompts[0]?.name).toBe('test_prompt');
});
});
describe('request context', () => {
it('should create context with default request id', () => {
const context = adapter.createContext();
expect(context.requestId).toBeTruthy();
});
it('should create context with specified request id', () => {
const context = adapter.createContext('test-request-123');
expect(context.requestId).toBe('test-request-123');
});
});
describe('query validation', () => {
it('should allow SELECT queries in read-only mode', () => {
expect(() => adapter.validateQuery('SELECT * FROM users', true)).not.toThrow();
});
it('should reject INSERT in read-only mode', () => {
expect(() => adapter.validateQuery('INSERT INTO users VALUES (1)', true)).toThrow();
});
it('should reject UPDATE in read-only mode', () => {
expect(() => adapter.validateQuery('UPDATE users SET name = "test"', true)).toThrow();
});
it('should reject DELETE in read-only mode', () => {
expect(() => adapter.validateQuery('DELETE FROM users WHERE id = 1', true)).toThrow();
});
it('should reject DROP in read-only mode', () => {
expect(() => adapter.validateQuery('DROP TABLE users', true)).toThrow();
});
it('should allow write queries in non-read-only mode', () => {
expect(() => adapter.validateQuery('INSERT INTO users VALUES (1)', false)).not.toThrow();
});
it('should reject dangerous patterns', () => {
expect(() => adapter.validateQuery('SELECT * FROM users; DROP TABLE users', false)).toThrow();
});
it('should reject CREATE in read-only mode', () => {
expect(() => adapter.validateQuery('CREATE TABLE test (id INT)', true)).toThrow();
});
it('should reject ALTER in read-only mode', () => {
expect(() => adapter.validateQuery('ALTER TABLE users ADD column email VARCHAR(255)', true)).toThrow();
});
it('should reject TRUNCATE in read-only mode', () => {
expect(() => adapter.validateQuery('TRUNCATE TABLE users', true)).toThrow();
});
it('should reject REPLACE in read-only mode', () => {
expect(() => adapter.validateQuery('REPLACE INTO users VALUES (1, "test")', true)).toThrow();
});
it('should reject GRANT in read-only mode', () => {
expect(() => adapter.validateQuery('GRANT SELECT ON users TO user1', true)).toThrow();
});
it('should reject REVOKE in read-only mode', () => {
expect(() => adapter.validateQuery('REVOKE SELECT ON users FROM user1', true)).toThrow();
});
it('should reject empty query', () => {
expect(() => adapter.validateQuery('', true)).toThrow('Query must be a non-empty string');
});
it('should reject non-string query', () => {
expect(() => adapter.validateQuery(null as never, true)).toThrow();
});
it('should reject SQL comment injection patterns', () => {
expect(() => adapter.validateQuery('SELECT * FROM users --', false)).toThrow();
});
it('should reject dangerous DELETE pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; DELETE FROM users', false)).toThrow();
});
it('should reject dangerous TRUNCATE pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; TRUNCATE users', false)).toThrow();
});
it('should reject dangerous INSERT pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; INSERT INTO logs VALUES (1)', false)).toThrow();
});
it('should reject dangerous UPDATE pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; UPDATE users SET x=1', false)).toThrow();
});
});
describe('MCP registration', () => {
let mockServer: {
registerTool: ReturnType<typeof vi.fn>;
registerResource: ReturnType<typeof vi.fn>;
registerPrompt: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockServer = {
registerTool: vi.fn(),
registerResource: vi.fn(),
registerPrompt: vi.fn()
};
});
describe('registerTools', () => {
it('should register enabled tools with server', () => {
const enabledTools = new Set(['test_tool']);
adapter.registerTools(mockServer as never, enabledTools);
expect(mockServer.registerTool).toHaveBeenCalled();
});
it('should not register tools not in enabled set', () => {
const enabledTools = new Set(['other_tool']);
adapter.registerTools(mockServer as never, enabledTools);
expect(mockServer.registerTool).not.toHaveBeenCalled();
});
it('should pass tool name and options to server', () => {
const enabledTools = new Set(['test_tool']);
adapter.registerTools(mockServer as never, enabledTools);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'test_tool',
expect.objectContaining({
description: 'A test tool',
title: 'Test Tool'
}),
expect.any(Function)
);
});
});
describe('registerResources', () => {
it('should register all resources with server', () => {
adapter.registerResources(mockServer as never);
expect(mockServer.registerResource).toHaveBeenCalled();
});
it('should pass resource name and uri to server', () => {
adapter.registerResources(mockServer as never);
expect(mockServer.registerResource).toHaveBeenCalledWith(
'Test Resource',
'mysql://test',
expect.anything(),
expect.any(Function)
);
});
});
describe('registerPrompts', () => {
it('should register all prompts with server', () => {
adapter.registerPrompts(mockServer as never);
expect(mockServer.registerPrompt).toHaveBeenCalled();
});
it('should pass prompt name and description to server', () => {
adapter.registerPrompts(mockServer as never);
expect(mockServer.registerPrompt).toHaveBeenCalledWith(
'test_prompt',
expect.objectContaining({
description: 'A test prompt'
}),
expect.any(Function)
);
});
it('should handle prompts with arguments', () => {
const promptWithArgs: PromptDefinition = {
name: 'arg_prompt',
description: 'desc',
arguments: [
{ name: 'required_arg', description: 'req', required: true },
{ name: 'optional_arg', description: 'opt', required: false }
],
handler: async () => 'result'
};
vi.spyOn(adapter, 'getPromptDefinitions').mockReturnValue([promptWithArgs]);
adapter.registerPrompts(mockServer as never);
expect(mockServer.registerPrompt).toHaveBeenCalledWith(
'arg_prompt',
expect.objectContaining({
description: 'desc',
argsSchema: expect.objectContaining({
required_arg: expect.anything(),
optional_arg: expect.anything()
})
}),
expect.any(Function)
);
});
});
describe('handler execution', () => {
it('should execute tool handler when called', async () => {
adapter.registerTools(mockServer as never, new Set(['test_tool']));
// registerTool now takes 3 args: name, options, handler
const handler = mockServer.registerTool.mock.calls[0][2] as Function;
const result = await handler({});
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ result: 'ok' }, null, 2) }]
});
});
it('should execute resource handler when called', async () => {
adapter.registerResources(mockServer as never);
const handler = mockServer.registerResource.mock.calls[0][3] as Function;
const result = await handler(new URL('mysql://test'));
expect(result).toEqual({
contents: [{
uri: 'mysql://test',
mimeType: 'application/json',
text: JSON.stringify({ data: 'test' }, null, 2)
}]
});
});
it('should execute prompt handler when called', async () => {
adapter.registerPrompts(mockServer as never);
// registerPrompt takes 3 args: name, options, handler
const handler = mockServer.registerPrompt.mock.calls[0][2] as Function;
const result = await handler({});
expect(result).toEqual({
messages: [{
role: 'user',
content: { type: 'text', text: 'Test prompt output' }
}]
});
});
});
});
});