feat: migrate to docker-compose with selfhosted-supabase-mcp
- 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
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user