/**
* Unit tests for DatabaseAdapter abstract class
*
* Tests the concrete methods in the abstract base class:
* - Query validation (SQL injection prevention)
* - MCP registration methods
* - Context creation
* - Adapter info
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DatabaseAdapter } from '../DatabaseAdapter.js';
import type {
DatabaseConfig,
QueryResult,
SchemaInfo,
TableInfo,
HealthStatus,
AdapterCapabilities,
ToolDefinition,
ResourceDefinition,
PromptDefinition,
ToolGroup,
DatabaseType
} from '../../types/index.js';
// Mock the logger to avoid console output during tests
vi.mock('../../utils/logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}));
/**
* Concrete implementation of DatabaseAdapter for testing
*/
class TestAdapter extends DatabaseAdapter {
readonly type: DatabaseType = 'postgresql';
readonly name = 'Test Adapter';
readonly version = '1.0.0';
private mockTools: ToolDefinition[] = [];
private mockResources: ResourceDefinition[] = [];
private mockPrompts: PromptDefinition[] = [];
// eslint-disable-next-line @typescript-eslint/require-await
async connect(_config: DatabaseConfig): Promise<void> {
this.connected = true;
}
// eslint-disable-next-line @typescript-eslint/require-await
async disconnect(): Promise<void> {
this.connected = false;
}
// eslint-disable-next-line @typescript-eslint/require-await
async getHealth(): Promise<HealthStatus> {
return {
connected: this.connected,
latencyMs: 5,
version: '16.1'
};
}
// eslint-disable-next-line @typescript-eslint/require-await
async executeReadQuery(_sql: string, _params?: unknown[]): Promise<QueryResult> {
return { rows: [], rowsAffected: 0, executionTimeMs: 1 };
}
// eslint-disable-next-line @typescript-eslint/require-await
async executeWriteQuery(_sql: string, _params?: unknown[]): Promise<QueryResult> {
return { rows: [], rowsAffected: 1, executionTimeMs: 1 };
}
// eslint-disable-next-line @typescript-eslint/require-await
async executeQuery(_sql: string, _params?: unknown[]): Promise<QueryResult> {
return { rows: [], rowsAffected: 0, executionTimeMs: 1 };
}
// eslint-disable-next-line @typescript-eslint/require-await
async getSchema(): Promise<SchemaInfo> {
return { tables: [], views: [], indexes: [] };
}
// eslint-disable-next-line @typescript-eslint/require-await
async listTables(): Promise<TableInfo[]> {
return [];
}
// eslint-disable-next-line @typescript-eslint/require-await
async describeTable(_tableName: string): Promise<TableInfo> {
return { name: 'test', schema: 'public', type: 'table', columns: [] };
}
// eslint-disable-next-line @typescript-eslint/require-await
async listSchemas(): Promise<string[]> {
return ['public'];
}
getCapabilities(): AdapterCapabilities {
return {
json: true,
fullTextSearch: true,
vector: false,
geospatial: false,
transactions: true,
preparedStatements: true,
connectionPooling: true,
partitioning: false,
replication: false,
cte: true,
windowFunctions: true
};
}
getSupportedToolGroups(): ToolGroup[] {
return ['core', 'transactions'];
}
getToolDefinitions(): ToolDefinition[] {
return this.mockTools;
}
getResourceDefinitions(): ResourceDefinition[] {
return this.mockResources;
}
getPromptDefinitions(): PromptDefinition[] {
return this.mockPrompts;
}
// Test helpers to set mock definitions
setMockTools(tools: ToolDefinition[]): void {
this.mockTools = tools;
}
setMockResources(resources: ResourceDefinition[]): void {
this.mockResources = resources;
}
setMockPrompts(prompts: PromptDefinition[]): void {
this.mockPrompts = prompts;
}
// Expose protected method for testing
testRegisterTool(server: unknown, tool: ToolDefinition): void {
this.registerTool(server as Parameters<typeof this.registerTool>[0], tool);
}
testRegisterResource(server: unknown, resource: ResourceDefinition): void {
this.registerResource(server as Parameters<typeof this.registerResource>[0], resource);
}
testRegisterPrompt(server: unknown, prompt: PromptDefinition): void {
this.registerPrompt(server as Parameters<typeof this.registerPrompt>[0], prompt);
}
}
describe('DatabaseAdapter', () => {
let adapter: TestAdapter;
beforeEach(() => {
adapter = new TestAdapter();
});
describe('isConnected', () => {
it('should return false initially', () => {
expect(adapter.isConnected()).toBe(false);
});
it('should return true after connect', async () => {
await adapter.connect({ type: 'postgresql', host: 'localhost', port: 5432, database: 'test' });
expect(adapter.isConnected()).toBe(true);
});
it('should return false after disconnect', async () => {
await adapter.connect({ type: 'postgresql', host: 'localhost', port: 5432, database: 'test' });
await adapter.disconnect();
expect(adapter.isConnected()).toBe(false);
});
});
describe('validateQuery', () => {
describe('dangerous pattern detection', () => {
it('should reject queries with ; DROP pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; DROP TABLE users', false)).toThrow(
'Query contains potentially dangerous patterns'
);
});
it('should reject queries with ; DELETE pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; DELETE FROM users', false)).toThrow(
'Query contains potentially dangerous patterns'
);
});
it('should reject queries with ; TRUNCATE pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; TRUNCATE users', false)).toThrow(
'Query contains potentially dangerous patterns'
);
});
it('should reject queries with ; INSERT pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; INSERT INTO users VALUES (1)', false)).toThrow(
'Query contains potentially dangerous patterns'
);
});
it('should reject queries with ; UPDATE pattern', () => {
expect(() => adapter.validateQuery('SELECT 1; UPDATE users SET name = "x"', false)).toThrow(
'Query contains potentially dangerous patterns'
);
});
it('should reject queries with SQL comment at end of line', () => {
expect(() => adapter.validateQuery('SELECT * FROM users-- ', false)).toThrow(
'Query contains potentially dangerous patterns'
);
});
it('should accept safe SELECT queries', () => {
expect(() => adapter.validateQuery('SELECT * FROM users WHERE id = 1', false)).not.toThrow();
});
it('should accept safe INSERT queries when not read-only', () => {
expect(() => adapter.validateQuery('INSERT INTO users (name) VALUES ($1)', false)).not.toThrow();
});
});
describe('read-only enforcement', () => {
it('should reject INSERT in read-only mode', () => {
expect(() => adapter.validateQuery('INSERT INTO users VALUES (1)', true)).toThrow(
'Read-only mode: INSERT statements are not allowed'
);
});
it('should reject UPDATE in read-only mode', () => {
expect(() => adapter.validateQuery('UPDATE users SET name = $1', true)).toThrow(
'Read-only mode: UPDATE statements are not allowed'
);
});
it('should reject DELETE in read-only mode', () => {
expect(() => adapter.validateQuery('DELETE FROM users WHERE id = 1', true)).toThrow(
'Read-only mode: DELETE statements are not allowed'
);
});
it('should reject DROP in read-only mode', () => {
expect(() => adapter.validateQuery('DROP TABLE users', true)).toThrow(
'Read-only mode: DROP statements are not allowed'
);
});
it('should reject CREATE in read-only mode', () => {
expect(() => adapter.validateQuery('CREATE TABLE test (id int)', true)).toThrow(
'Read-only mode: CREATE statements are not allowed'
);
});
it('should reject ALTER in read-only mode', () => {
expect(() => adapter.validateQuery('ALTER TABLE users ADD COLUMN email text', true)).toThrow(
'Read-only mode: ALTER statements are not allowed'
);
});
it('should reject TRUNCATE in read-only mode', () => {
expect(() => adapter.validateQuery('TRUNCATE users', true)).toThrow(
'Read-only mode: TRUNCATE statements are not allowed'
);
});
it('should reject GRANT in read-only mode', () => {
expect(() => adapter.validateQuery('GRANT SELECT ON users TO reader', true)).toThrow(
'Read-only mode: GRANT statements are not allowed'
);
});
it('should reject REVOKE in read-only mode', () => {
expect(() => adapter.validateQuery('REVOKE SELECT ON users FROM reader', true)).toThrow(
'Read-only mode: REVOKE statements are not allowed'
);
});
it('should allow SELECT in read-only mode', () => {
expect(() => adapter.validateQuery('SELECT * FROM users', true)).not.toThrow();
});
it('should allow EXPLAIN in read-only mode', () => {
expect(() => adapter.validateQuery('EXPLAIN SELECT * FROM users', true)).not.toThrow();
});
});
describe('input validation', () => {
it('should throw for empty string', () => {
expect(() => adapter.validateQuery('', false)).toThrow('Query must be a non-empty string');
});
it('should throw for non-string input', () => {
expect(() => adapter.validateQuery(null as unknown as string, false)).toThrow(
'Query must be a non-empty string'
);
});
it('should throw for undefined input', () => {
expect(() => adapter.validateQuery(undefined as unknown as string, false)).toThrow(
'Query must be a non-empty string'
);
});
});
});
describe('createContext', () => {
it('should create context with timestamp', () => {
const context = adapter.createContext();
expect(context.timestamp).toBeInstanceOf(Date);
});
it('should create context with generated requestId', () => {
const context = adapter.createContext();
expect(context.requestId).toBeDefined();
expect(typeof context.requestId).toBe('string');
expect(context.requestId.length).toBeGreaterThan(0);
});
it('should use provided requestId', () => {
const customId = 'custom-request-123';
const context = adapter.createContext(customId);
expect(context.requestId).toBe(customId);
});
it('should generate unique requestIds for different calls', () => {
const context1 = adapter.createContext();
const context2 = adapter.createContext();
expect(context1.requestId).not.toBe(context2.requestId);
});
});
describe('getInfo', () => {
it('should return adapter type', () => {
const info = adapter.getInfo();
expect(info['type']).toBe('postgresql');
});
it('should return adapter name', () => {
const info = adapter.getInfo();
expect(info['name']).toBe('Test Adapter');
});
it('should return adapter version', () => {
const info = adapter.getInfo();
expect(info['version']).toBe('1.0.0');
});
it('should return connected status', () => {
const info = adapter.getInfo();
expect(info['connected']).toBe(false);
});
it('should return capabilities', () => {
const info = adapter.getInfo();
expect(info['capabilities']).toBeDefined();
expect((info['capabilities'] as AdapterCapabilities).json).toBe(true);
});
it('should return tool groups', () => {
const info = adapter.getInfo();
expect(info['toolGroups']).toBeDefined();
expect(info['toolGroups']).toContain('core');
});
});
describe('registerTools', () => {
it('should register only enabled tools', () => {
const mockServer = {
tool: vi.fn()
};
const tools: ToolDefinition[] = [
{
name: 'pg_query',
description: 'Execute query',
group: 'core',
tags: ['query'],
inputSchema: {},
handler: vi.fn()
},
{
name: 'pg_insert',
description: 'Insert data',
group: 'core',
tags: ['insert'],
inputSchema: {},
handler: vi.fn()
}
];
adapter.setMockTools(tools);
const enabledTools = new Set(['pg_query']);
adapter.registerTools(mockServer as unknown as Parameters<typeof adapter.registerTools>[0], enabledTools);
// Should only register pg_query
expect(mockServer.tool).toHaveBeenCalledTimes(1);
expect(mockServer.tool.mock.calls[0]?.[0]).toBe('pg_query');
});
it('should register no tools if none are enabled', () => {
const mockServer = {
tool: vi.fn()
};
adapter.setMockTools([
{ name: 'pg_query', description: 'test', group: 'core', tags: [], inputSchema: {}, handler: vi.fn() }
]);
adapter.registerTools(mockServer as unknown as Parameters<typeof adapter.registerTools>[0], new Set());
expect(mockServer.tool).not.toHaveBeenCalled();
});
});
describe('registerTool', () => {
it('should register tool with correct name and description', () => {
const mockServer = {
tool: vi.fn()
};
const tool: ToolDefinition = {
name: 'pg_test_tool',
description: 'A test tool',
group: 'core',
tags: ['test'],
inputSchema: {},
handler: vi.fn()
};
adapter.testRegisterTool(mockServer, tool);
expect(mockServer.tool).toHaveBeenCalledWith(
'pg_test_tool',
'A test tool',
expect.anything(),
expect.anything(),
expect.any(Function)
);
});
it('should include annotations in metadata', () => {
const mockServer = {
tool: vi.fn()
};
const tool: ToolDefinition = {
name: 'pg_read_tool',
description: 'A read-only tool',
group: 'core',
tags: ['read'],
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false
},
handler: vi.fn()
};
adapter.testRegisterTool(mockServer, tool);
const metadata = mockServer.tool.mock.calls[0]?.[3] as Record<string, unknown>;
expect(metadata['readOnlyHint']).toBe(true);
expect(metadata['destructiveHint']).toBe(false);
});
it('should include icons in metadata when present', () => {
const mockServer = {
tool: vi.fn()
};
const tool: ToolDefinition = {
name: 'pg_icon_tool',
description: 'Tool with icons',
group: 'core',
tags: [],
inputSchema: {},
icons: [{ src: 'data:image/svg+xml;base64,test', mimeType: 'image/svg+xml' }],
handler: vi.fn()
};
adapter.testRegisterTool(mockServer, tool);
const metadata = mockServer.tool.mock.calls[0]?.[3] as Record<string, unknown>;
expect(metadata['icons']).toEqual([{ src: 'data:image/svg+xml;base64,test', mimeType: 'image/svg+xml' }]);
});
});
describe('registerResources', () => {
it('should register all resources', () => {
const mockServer = {
registerResource: vi.fn()
};
const resources: ResourceDefinition[] = [
{ name: 'schema', uri: 'postgres://schema', description: 'Schema info', handler: vi.fn() },
{ name: 'tables', uri: 'postgres://tables', description: 'Table list', handler: vi.fn() }
];
adapter.setMockResources(resources);
adapter.registerResources(mockServer as unknown as Parameters<typeof adapter.registerResources>[0]);
expect(mockServer.registerResource).toHaveBeenCalledTimes(2);
});
});
describe('registerResource', () => {
it('should register resource with correct name and URI', () => {
const mockServer = {
registerResource: vi.fn()
};
const resource: ResourceDefinition = {
name: 'test_resource',
uri: 'postgres://test',
description: 'Test resource',
handler: vi.fn()
};
adapter.testRegisterResource(mockServer, resource);
expect(mockServer.registerResource).toHaveBeenCalledWith(
'test_resource',
'postgres://test',
expect.objectContaining({
description: 'Test resource',
mimeType: 'application/json'
}),
expect.any(Function)
);
});
it('should use custom mimeType when provided', () => {
const mockServer = {
registerResource: vi.fn()
};
const resource: ResourceDefinition = {
name: 'text_resource',
uri: 'postgres://text',
description: 'Text resource',
mimeType: 'text/plain',
handler: vi.fn()
};
adapter.testRegisterResource(mockServer, resource);
const options = mockServer.registerResource.mock.calls[0]?.[2] as { mimeType: string };
expect(options.mimeType).toBe('text/plain');
});
});
describe('registerPrompts', () => {
it('should register all prompts', () => {
const mockServer = {
prompt: vi.fn()
};
const prompts: PromptDefinition[] = [
{ name: 'prompt1', description: 'Prompt 1', handler: vi.fn() },
{ name: 'prompt2', description: 'Prompt 2', handler: vi.fn() }
];
adapter.setMockPrompts(prompts);
adapter.registerPrompts(mockServer as unknown as Parameters<typeof adapter.registerPrompts>[0]);
expect(mockServer.prompt).toHaveBeenCalledTimes(2);
});
});
describe('registerPrompt', () => {
it('should register prompt with correct name and description', () => {
const mockServer = {
prompt: vi.fn()
};
const prompt: PromptDefinition = {
name: 'test_prompt',
description: 'A test prompt',
handler: vi.fn()
};
adapter.testRegisterPrompt(mockServer, prompt);
expect(mockServer.prompt).toHaveBeenCalledWith(
'test_prompt',
'A test prompt',
expect.anything(),
expect.any(Function)
);
});
it('should build Zod schema from prompt arguments', () => {
const mockServer = {
prompt: vi.fn()
};
const prompt: PromptDefinition = {
name: 'parameterized_prompt',
description: 'Prompt with args',
arguments: [
{ name: 'tableName', description: 'Table name', required: true },
{ name: 'limit', description: 'Row limit', required: false }
],
handler: vi.fn()
};
adapter.testRegisterPrompt(mockServer, prompt);
const zodShape = mockServer.prompt.mock.calls[0]?.[2] as Record<string, unknown>;
expect(zodShape).toHaveProperty('tableName');
expect(zodShape).toHaveProperty('limit');
});
it('should invoke prompt handler and return result as message', async () => {
const mockServer = {
prompt: vi.fn()
};
const mockHandler = vi.fn().mockResolvedValue({ response: 'test result' });
const prompt: PromptDefinition = {
name: 'invoke_prompt',
description: 'Prompt to invoke',
arguments: [
{ name: 'arg1', description: 'Arg 1', required: true }
],
handler: mockHandler
};
adapter.testRegisterPrompt(mockServer, prompt);
// Get the handler that was passed to server.prompt
const registeredHandler = mockServer.prompt.mock.calls[0]?.[3] as (args: Record<string, string>) => Promise<unknown>;
const result = await registeredHandler({ arg1: 'value1' });
expect(mockHandler).toHaveBeenCalled();
expect(result).toHaveProperty('messages');
});
it('should invoke prompt handler returning string result', async () => {
const mockServer = {
prompt: vi.fn()
};
const mockHandler = vi.fn().mockResolvedValue('plain string result');
const prompt: PromptDefinition = {
name: 'string_prompt',
description: 'Prompt returning string',
handler: mockHandler
};
adapter.testRegisterPrompt(mockServer, prompt);
const registeredHandler = mockServer.prompt.mock.calls[0]?.[3] as (args: Record<string, string>) => Promise<unknown>;
const result = await registeredHandler({});
expect(result).toHaveProperty('messages');
const messages = (result as { messages: Array<{ content: { text: string } }> }).messages;
expect(messages[0]?.content.text).toBe('plain string result');
});
});
describe('handler invocation', () => {
it('should invoke tool handler and JSON stringify object results', async () => {
const mockServer = {
tool: vi.fn()
};
const mockHandler = vi.fn().mockResolvedValue({ data: 'test', count: 42 });
const tool: ToolDefinition = {
name: 'pg_object_result',
description: 'Tool returning object',
group: 'core',
tags: [],
inputSchema: {},
handler: mockHandler
};
adapter.testRegisterTool(mockServer, tool);
// Get the handler that was passed to server.tool (5th argument)
const registeredHandler = mockServer.tool.mock.calls[0]?.[4] as (params: unknown) => Promise<unknown>;
const result = await registeredHandler({});
expect(mockHandler).toHaveBeenCalled();
expect(result).toHaveProperty('content');
const content = (result as { content: Array<{ type: string; text: string }> }).content;
expect(content[0]?.type).toBe('text');
expect(content[0]?.text).toContain('"data"');
expect(content[0]?.text).toContain('"count"');
});
it('should invoke tool handler and return string results directly', async () => {
const mockServer = {
tool: vi.fn()
};
const mockHandler = vi.fn().mockResolvedValue('plain text result');
const tool: ToolDefinition = {
name: 'pg_string_result',
description: 'Tool returning string',
group: 'core',
tags: [],
inputSchema: {},
handler: mockHandler
};
adapter.testRegisterTool(mockServer, tool);
const registeredHandler = mockServer.tool.mock.calls[0]?.[4] as (params: unknown) => Promise<unknown>;
const result = await registeredHandler({});
const content = (result as { content: Array<{ type: string; text: string }> }).content;
expect(content[0]?.text).toBe('plain text result');
});
it('should invoke resource handler and JSON stringify object results', async () => {
const mockServer = {
registerResource: vi.fn()
};
const mockHandler = vi.fn().mockResolvedValue({ tables: ['users', 'orders'] });
const resource: ResourceDefinition = {
name: 'object_resource',
uri: 'postgres://object',
description: 'Resource returning object',
handler: mockHandler
};
adapter.testRegisterResource(mockServer, resource);
// Get the handler that was passed to registerResource (4th argument)
const registeredHandler = mockServer.registerResource.mock.calls[0]?.[3] as (uri: URL) => Promise<unknown>;
const result = await registeredHandler(new URL('postgres://object'));
expect(mockHandler).toHaveBeenCalled();
expect(result).toHaveProperty('contents');
const contents = (result as { contents: Array<{ text: string }> }).contents;
expect(contents[0]?.text).toContain('"tables"');
});
it('should invoke resource handler and return string results directly', async () => {
const mockServer = {
registerResource: vi.fn()
};
const mockHandler = vi.fn().mockResolvedValue('plain resource text');
const resource: ResourceDefinition = {
name: 'string_resource',
uri: 'postgres://string',
description: 'Resource returning string',
handler: mockHandler
};
adapter.testRegisterResource(mockServer, resource);
const registeredHandler = mockServer.registerResource.mock.calls[0]?.[3] as (uri: URL) => Promise<unknown>;
const result = await registeredHandler(new URL('postgres://string'));
const contents = (result as { contents: Array<{ text: string }> }).contents;
expect(contents[0]?.text).toBe('plain resource text');
});
});
});