/** * Tests for database-related tools * * Tools tested: * - list_tables * - list_extensions * - get_database_connections * - get_database_stats * - list_migrations * - apply_migration */ import { describe, test, expect } from 'bun:test'; import { listTablesTool } from '../../tools/list_tables.js'; import { listExtensionsTool } from '../../tools/list_extensions.js'; import { getDatabaseConnectionsTool } from '../../tools/get_database_connections.js'; import { getDatabaseStatsTool } from '../../tools/get_database_stats.js'; import { createMockClient, createMockContext, createSuccessResponse, createErrorResponse, testData, } from '../helpers/mocks.js'; describe('listTablesTool', () => { describe('metadata', () => { test('has correct name', () => { expect(listTablesTool.name).toBe('list_tables'); }); test('has description', () => { expect(listTablesTool.description).toBeDefined(); expect(listTablesTool.description).toContain('table'); }); test('has input and output schemas', () => { expect(listTablesTool.inputSchema).toBeDefined(); expect(listTablesTool.outputSchema).toBeDefined(); expect(listTablesTool.mcpInputSchema).toBeDefined(); }); }); describe('execute', () => { test('returns list of tables', async () => { const tables = [ { schema: 'public', name: 'users', comment: 'User accounts' }, { schema: 'public', name: 'posts', comment: null }, ]; const mockClient = createMockClient({ pgAvailable: true, pgResult: createSuccessResponse(tables), }); const context = createMockContext(mockClient); const result = await listTablesTool.execute({}, context); expect(result).toEqual(tables); }); test('returns empty array when no tables exist', async () => { const mockClient = createMockClient({ pgAvailable: true, pgResult: createSuccessResponse([]), }); const context = createMockContext(mockClient); const result = await listTablesTool.execute({}, context); expect(result).toEqual([]); }); test('throws error on SQL failure', async () => { const mockClient = createMockClient({ pgAvailable: true, pgResult: createErrorResponse('permission denied', '42501'), }); const context = createMockContext(mockClient); await expect(listTablesTool.execute({}, context)).rejects.toThrow('SQL Error'); }); test('uses read-only mode for query via service role RPC', async () => { const mockClient = createMockClient({ pgAvailable: false, serviceRoleAvailable: true, serviceRoleRpcResult: createSuccessResponse([]), }); const context = createMockContext(mockClient); await listTablesTool.execute({}, context); // When using service role RPC, should be called with readOnly=true expect(mockClient.executeSqlViaServiceRoleRpc).toHaveBeenCalledWith( expect.any(String), true ); }); }); describe('output validation', () => { test('validates correct table structure', () => { const validOutput = [ { schema: 'public', name: 'users', comment: 'User table' }, { schema: 'public', name: 'posts', comment: null }, ]; const result = listTablesTool.outputSchema.safeParse(validOutput); expect(result.success).toBe(true); }); test('rejects missing schema field', () => { const invalidOutput = [{ name: 'users', comment: null }]; const result = listTablesTool.outputSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); test('rejects missing name field', () => { const invalidOutput = [{ schema: 'public', comment: null }]; const result = listTablesTool.outputSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); }); }); describe('listExtensionsTool', () => { describe('metadata', () => { test('has correct name', () => { expect(listExtensionsTool.name).toBe('list_extensions'); }); test('has description', () => { expect(listExtensionsTool.description).toContain('extension'); }); }); describe('execute', () => { test('returns list of extensions', async () => { const extensions = [ { name: 'uuid-ossp', schema: 'extensions', version: '1.1', description: 'UUID functions' }, { name: 'pgcrypto', schema: 'extensions', version: '1.3', description: null }, ]; const mockClient = createMockClient({ pgAvailable: true, pgResult: createSuccessResponse(extensions), }); const context = createMockContext(mockClient); const result = await listExtensionsTool.execute({}, context); expect(result).toEqual(extensions); }); test('returns empty array when no extensions installed', async () => { const mockClient = createMockClient({ pgAvailable: true, pgResult: createSuccessResponse([]), }); const context = createMockContext(mockClient); const result = await listExtensionsTool.execute({}, context); expect(result).toEqual([]); }); test('throws error on SQL failure', async () => { const mockClient = createMockClient({ pgAvailable: true, pgResult: createErrorResponse('access denied', '42501'), }); const context = createMockContext(mockClient); await expect(listExtensionsTool.execute({}, context)).rejects.toThrow('SQL Error'); }); }); describe('output validation', () => { test('validates correct extension structure', () => { const validOutput = [ { name: 'uuid-ossp', schema: 'public', version: '1.1', description: 'UUID gen' }, ]; const result = listExtensionsTool.outputSchema.safeParse(validOutput); expect(result.success).toBe(true); }); test('accepts null description', () => { const output = [ { name: 'ext', schema: 'public', version: '1.0', description: null }, ]; const result = listExtensionsTool.outputSchema.safeParse(output); expect(result.success).toBe(true); }); test('rejects missing required fields', () => { const invalidOutput = [{ name: 'ext' }]; const result = listExtensionsTool.outputSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); }); }); describe('getDatabaseConnectionsTool', () => { describe('metadata', () => { test('has correct name', () => { expect(getDatabaseConnectionsTool.name).toBe('get_database_connections'); }); test('has description about connections', () => { expect(getDatabaseConnectionsTool.description).toContain('connection'); }); }); describe('execute', () => { test('returns list of connections', async () => { const connections = [ { pid: 12345, datname: 'postgres', usename: 'postgres', application_name: 'psql', client_addr: '127.0.0.1', backend_start: '2024-01-01T00:00:00Z', state: 'active', query: 'SELECT 1', }, ]; const mockClient = createMockClient({ pgAvailable: true, pgResult: createSuccessResponse(connections), }); const context = createMockContext(mockClient); const result = await getDatabaseConnectionsTool.execute({}, context); expect(result).toEqual(connections); }); test('returns empty array when no connections', async () => { const mockClient = createMockClient({ pgAvailable: true, pgResult: createSuccessResponse([]), }); const context = createMockContext(mockClient); const result = await getDatabaseConnectionsTool.execute({}, context); expect(result).toEqual([]); }); test('handles connections with null values', async () => { const connections = [ { pid: 1, datname: null, usename: null, application_name: null, client_addr: null, backend_start: null, state: null, query: null, }, ]; const mockClient = createMockClient({ pgAvailable: true, pgResult: createSuccessResponse(connections), }); const context = createMockContext(mockClient); const result = await getDatabaseConnectionsTool.execute({}, context); expect(result).toEqual(connections); }); test('throws error on SQL failure', async () => { const mockClient = createMockClient({ pgAvailable: true, pgResult: createErrorResponse('permission denied for pg_stat_activity', '42501'), }); const context = createMockContext(mockClient); await expect(getDatabaseConnectionsTool.execute({}, context)).rejects.toThrow('SQL Error'); }); }); describe('output validation', () => { test('requires pid to be a number', () => { const invalidOutput = [{ pid: 'not-a-number' }]; const result = getDatabaseConnectionsTool.outputSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); test('accepts complete connection object', () => { const validOutput = [ { pid: 123, datname: 'db', usename: 'user', application_name: 'app', client_addr: '127.0.0.1', backend_start: '2024-01-01', state: 'idle', query: 'SELECT 1', }, ]; const result = getDatabaseConnectionsTool.outputSchema.safeParse(validOutput); expect(result.success).toBe(true); }); }); }); describe('getDatabaseStatsTool', () => { describe('metadata', () => { test('has correct name', () => { expect(getDatabaseStatsTool.name).toBe('get_database_stats'); }); test('has description about statistics', () => { expect(getDatabaseStatsTool.description).toContain('statistic'); }); }); describe('execute', () => { test('returns combined database and bgwriter stats', async () => { const dbStats = [ { datname: 'postgres', numbackends: 5, xact_commit: '1000', xact_rollback: '10', blks_read: '500', blks_hit: '9500', tup_returned: '10000', tup_fetched: '5000', tup_inserted: '100', tup_updated: '50', tup_deleted: '10', conflicts: '0', temp_files: '0', temp_bytes: '0', deadlocks: '0', checksum_failures: null, checksum_last_failure: null, blk_read_time: 1.5, blk_write_time: 0.5, stats_reset: '2024-01-01T00:00:00Z', }, ]; const bgWriterStats = [ { checkpoints_timed: '100', checkpoints_req: '5', checkpoint_write_time: 1000.0, checkpoint_sync_time: 50.0, buffers_checkpoint: '500', buffers_clean: '100', maxwritten_clean: '0', buffers_backend: '50', buffers_backend_fsync: '0', buffers_alloc: '1000', stats_reset: '2024-01-01T00:00:00Z', }, ]; // Mock client needs to return different results for the two queries let callCount = 0; const mockClient = createMockClient({ pgAvailable: true }); (mockClient.executeSqlWithPg as ReturnType).mockImplementation( async () => { callCount++; return callCount === 1 ? dbStats : bgWriterStats; } ); const context = createMockContext(mockClient); const result = await getDatabaseStatsTool.execute({}, context); expect(result).toHaveProperty('database_stats'); expect(result).toHaveProperty('bgwriter_stats'); expect(result.database_stats).toEqual(dbStats); expect(result.bgwriter_stats).toEqual(bgWriterStats); }); test('throws error when database stats query fails', async () => { let callCount = 0; const mockClient = createMockClient({ pgAvailable: true }); (mockClient.executeSqlWithPg as ReturnType).mockImplementation( async () => { callCount++; if (callCount === 1) { return createErrorResponse('query failed', 'ERROR'); } return []; } ); const context = createMockContext(mockClient); await expect(getDatabaseStatsTool.execute({}, context)).rejects.toThrow('SQL Error'); }); test('throws error when bgwriter stats query fails', async () => { let callCount = 0; const mockClient = createMockClient({ pgAvailable: true }); (mockClient.executeSqlWithPg as ReturnType).mockImplementation( async () => { callCount++; if (callCount === 2) { return createErrorResponse('query failed', 'ERROR'); } return [ { datname: 'test', numbackends: 1, xact_commit: '0', xact_rollback: '0', blks_read: '0', blks_hit: '0', tup_returned: '0', tup_fetched: '0', tup_inserted: '0', tup_updated: '0', tup_deleted: '0', conflicts: '0', temp_files: '0', temp_bytes: '0', deadlocks: '0', checksum_failures: null, checksum_last_failure: null, blk_read_time: 0, blk_write_time: 0, stats_reset: null, }, ]; } ); const context = createMockContext(mockClient); await expect(getDatabaseStatsTool.execute({}, context)).rejects.toThrow('SQL Error'); }); }); describe('output validation', () => { test('validates correct stats structure', () => { const validOutput = { database_stats: [ { datname: 'test', numbackends: 1, xact_commit: '0', xact_rollback: '0', blks_read: '0', blks_hit: '0', tup_returned: '0', tup_fetched: '0', tup_inserted: '0', tup_updated: '0', tup_deleted: '0', conflicts: '0', temp_files: '0', temp_bytes: '0', deadlocks: '0', checksum_failures: null, checksum_last_failure: null, blk_read_time: 0, blk_write_time: 0, stats_reset: null, }, ], bgwriter_stats: [ { checkpoints_timed: '0', checkpoints_req: '0', checkpoint_write_time: 0, checkpoint_sync_time: 0, buffers_checkpoint: '0', buffers_clean: '0', maxwritten_clean: '0', buffers_backend: '0', buffers_backend_fsync: '0', buffers_alloc: '0', stats_reset: null, }, ], }; const result = getDatabaseStatsTool.outputSchema.safeParse(validOutput); expect(result.success).toBe(true); }); test('rejects missing database_stats', () => { const invalidOutput = { bgwriter_stats: [] }; const result = getDatabaseStatsTool.outputSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); test('rejects missing bgwriter_stats', () => { const invalidOutput = { database_stats: [] }; const result = getDatabaseStatsTool.outputSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); }); });