- Replace legacy directory structure (api/, db/, functions/, logs/, pooler/) with single docker-compose.yml based self-hosted setup - Add selfhosted-supabase-mcp TypeScript MCP server for database management - Add .dockerignore for Docker build context - Update .gitignore to exclude .env files, volumes/, backups, logs
500 lines
19 KiB
TypeScript
500 lines
19 KiB
TypeScript
/**
|
|
* 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<typeof import('bun:test').mock>).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<typeof import('bun:test').mock>).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<typeof import('bun:test').mock>).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);
|
|
});
|
|
});
|
|
});
|