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,443 @@
|
||||
/**
|
||||
* Tests for SelfhostedSupabaseClient
|
||||
*
|
||||
* These tests verify the core client functionality including:
|
||||
* - Client initialization and validation
|
||||
* - SQL execution via RPC
|
||||
* - SQL execution via direct pg connection
|
||||
* - Transaction handling
|
||||
* - Getter methods
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach, spyOn } from 'bun:test';
|
||||
import { SelfhostedSupabaseClient } from '../../client/index.js';
|
||||
import type { SelfhostedSupabaseClientOptions } from '../../types/index.js';
|
||||
|
||||
// Mock the external dependencies
|
||||
const mockSupabaseClient = {
|
||||
rpc: mock(() => Promise.resolve({ data: [], error: null })),
|
||||
};
|
||||
|
||||
const mockCreateClient = mock(() => mockSupabaseClient);
|
||||
|
||||
// Mock @supabase/supabase-js
|
||||
mock.module('@supabase/supabase-js', () => ({
|
||||
createClient: mockCreateClient,
|
||||
}));
|
||||
|
||||
// Mock pg Pool
|
||||
const mockPoolClient = {
|
||||
query: mock(() => Promise.resolve({ rows: [] })),
|
||||
release: mock(() => {}),
|
||||
};
|
||||
|
||||
const mockPool = {
|
||||
connect: mock(() => Promise.resolve(mockPoolClient)),
|
||||
end: mock(() => Promise.resolve()),
|
||||
on: mock(() => {}),
|
||||
};
|
||||
|
||||
const mockPoolConstructor = mock(() => mockPool);
|
||||
|
||||
mock.module('pg', () => ({
|
||||
Pool: mockPoolConstructor,
|
||||
}));
|
||||
|
||||
describe('SelfhostedSupabaseClient', () => {
|
||||
const validOptions: SelfhostedSupabaseClientOptions = {
|
||||
supabaseUrl: 'https://test.supabase.co',
|
||||
supabaseAnonKey: 'test-anon-key',
|
||||
supabaseServiceRoleKey: 'test-service-role-key',
|
||||
databaseUrl: 'postgresql://postgres:postgres@localhost:5432/postgres',
|
||||
jwtSecret: 'test-jwt-secret',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
mockCreateClient.mockClear();
|
||||
mockSupabaseClient.rpc.mockClear();
|
||||
mockPool.connect.mockClear();
|
||||
mockPool.end.mockClear();
|
||||
mockPoolClient.query.mockClear();
|
||||
mockPoolClient.release.mockClear();
|
||||
|
||||
// Reset to default successful behavior
|
||||
mockSupabaseClient.rpc.mockImplementation(() =>
|
||||
Promise.resolve({ data: [], error: null })
|
||||
);
|
||||
mockPoolClient.query.mockImplementation(() =>
|
||||
Promise.resolve({ rows: [] })
|
||||
);
|
||||
});
|
||||
|
||||
describe('create() factory method', () => {
|
||||
test('creates client with valid options', async () => {
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
|
||||
expect(client).toBeDefined();
|
||||
expect(mockCreateClient).toHaveBeenCalledWith(
|
||||
validOptions.supabaseUrl,
|
||||
validOptions.supabaseAnonKey,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test('throws error when supabaseUrl is missing', async () => {
|
||||
const invalidOptions = {
|
||||
...validOptions,
|
||||
supabaseUrl: '',
|
||||
};
|
||||
|
||||
await expect(SelfhostedSupabaseClient.create(invalidOptions)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('throws error when supabaseAnonKey is missing', async () => {
|
||||
const invalidOptions = {
|
||||
...validOptions,
|
||||
supabaseAnonKey: '',
|
||||
};
|
||||
|
||||
await expect(SelfhostedSupabaseClient.create(invalidOptions)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getters', () => {
|
||||
test('getSupabaseUrl returns configured URL', async () => {
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
expect(client.getSupabaseUrl()).toBe(validOptions.supabaseUrl);
|
||||
});
|
||||
|
||||
test('getAnonKey returns configured anon key', async () => {
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
expect(client.getAnonKey()).toBe(validOptions.supabaseAnonKey);
|
||||
});
|
||||
|
||||
test('getServiceRoleKey returns configured service role key', async () => {
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
expect(client.getServiceRoleKey()).toBe(validOptions.supabaseServiceRoleKey);
|
||||
});
|
||||
|
||||
test('getServiceRoleKey returns undefined when not configured', async () => {
|
||||
const optionsWithoutServiceKey = {
|
||||
supabaseUrl: validOptions.supabaseUrl,
|
||||
supabaseAnonKey: validOptions.supabaseAnonKey,
|
||||
};
|
||||
const client = await SelfhostedSupabaseClient.create(optionsWithoutServiceKey);
|
||||
expect(client.getServiceRoleKey()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getJwtSecret returns configured JWT secret', async () => {
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
expect(client.getJwtSecret()).toBe(validOptions.jwtSecret);
|
||||
});
|
||||
|
||||
test('getDbUrl returns configured database URL', async () => {
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
expect(client.getDbUrl()).toBe(validOptions.databaseUrl);
|
||||
});
|
||||
|
||||
test('isPgAvailable returns true when databaseUrl is configured', async () => {
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
expect(client.isPgAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
test('isPgAvailable returns false when databaseUrl is not configured', async () => {
|
||||
const optionsWithoutDb = {
|
||||
supabaseUrl: validOptions.supabaseUrl,
|
||||
supabaseAnonKey: validOptions.supabaseAnonKey,
|
||||
};
|
||||
const client = await SelfhostedSupabaseClient.create(optionsWithoutDb);
|
||||
expect(client.isPgAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeSqlViaRpc', () => {
|
||||
test('returns success response for valid query', async () => {
|
||||
const expectedData = [{ id: 1, name: 'test' }];
|
||||
mockSupabaseClient.rpc.mockImplementation(() =>
|
||||
Promise.resolve({ data: expectedData, error: null })
|
||||
);
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
const result = await client.executeSqlViaRpc('SELECT * FROM users');
|
||||
|
||||
expect(result).toEqual(expectedData);
|
||||
expect(mockSupabaseClient.rpc).toHaveBeenCalledWith('execute_sql', {
|
||||
query: 'SELECT * FROM users',
|
||||
read_only: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('passes read_only parameter correctly', async () => {
|
||||
mockSupabaseClient.rpc.mockImplementation(() =>
|
||||
Promise.resolve({ data: [], error: null })
|
||||
);
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
await client.executeSqlViaRpc('SELECT 1', true);
|
||||
|
||||
expect(mockSupabaseClient.rpc).toHaveBeenCalledWith('execute_sql', {
|
||||
query: 'SELECT 1',
|
||||
read_only: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns error response when RPC fails', async () => {
|
||||
// First call succeeds (initialization check), second call fails
|
||||
let callCount = 0;
|
||||
mockSupabaseClient.rpc.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// Initialization check succeeds
|
||||
return Promise.resolve({ data: [], error: null });
|
||||
}
|
||||
// Actual query fails
|
||||
return Promise.resolve({
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Query failed',
|
||||
code: 'P0001',
|
||||
details: 'Some details',
|
||||
hint: 'Try something else',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
const result = await client.executeSqlViaRpc('INVALID SQL');
|
||||
|
||||
expect(result).toHaveProperty('error');
|
||||
expect((result as { error: { message: string } }).error.message).toBe('Query failed');
|
||||
expect((result as { error: { code: string } }).error.code).toBe('P0001');
|
||||
});
|
||||
|
||||
test('returns error when RPC function does not exist', async () => {
|
||||
// First call during initialization - function doesn't exist
|
||||
mockSupabaseClient.rpc.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
data: null,
|
||||
error: { message: 'Function not found', code: '42883' },
|
||||
})
|
||||
);
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create({
|
||||
...validOptions,
|
||||
supabaseServiceRoleKey: undefined,
|
||||
databaseUrl: undefined,
|
||||
});
|
||||
|
||||
const result = await client.executeSqlViaRpc('SELECT 1');
|
||||
|
||||
expect(result).toHaveProperty('error');
|
||||
expect((result as { error: { message: string } }).error.message).toContain(
|
||||
'execute_sql RPC function not found'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles unexpected response format', async () => {
|
||||
// First call succeeds (initialization), second returns bad format
|
||||
let callCount = 0;
|
||||
mockSupabaseClient.rpc.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({ data: [], error: null });
|
||||
}
|
||||
return Promise.resolve({ data: 'not an array', error: null });
|
||||
});
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
const result = await client.executeSqlViaRpc('SELECT 1');
|
||||
|
||||
expect(result).toHaveProperty('error');
|
||||
expect((result as { error: { code: string } }).error.code).toBe('MCP_RPC_FORMAT_ERROR');
|
||||
});
|
||||
|
||||
test('handles RPC exceptions during query', async () => {
|
||||
// First call succeeds (initialization), second throws
|
||||
let callCount = 0;
|
||||
mockSupabaseClient.rpc.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({ data: [], error: null });
|
||||
}
|
||||
return Promise.reject(new Error('Network error'));
|
||||
});
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
const result = await client.executeSqlViaRpc('SELECT 1');
|
||||
|
||||
expect(result).toHaveProperty('error');
|
||||
expect((result as { error: { code: string } }).error.code).toBe('MCP_RPC_EXCEPTION');
|
||||
expect((result as { error: { message: string } }).error.message).toContain('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeSqlWithPg', () => {
|
||||
test('returns success response for valid query', async () => {
|
||||
const expectedRows = [{ id: 1, name: 'test' }];
|
||||
mockPoolClient.query.mockImplementation(() =>
|
||||
Promise.resolve({ rows: expectedRows })
|
||||
);
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
const result = await client.executeSqlWithPg('SELECT * FROM users');
|
||||
|
||||
expect(result).toEqual(expectedRows);
|
||||
});
|
||||
|
||||
test('returns error when databaseUrl is not configured', async () => {
|
||||
const optionsWithoutDb = {
|
||||
supabaseUrl: validOptions.supabaseUrl,
|
||||
supabaseAnonKey: validOptions.supabaseAnonKey,
|
||||
};
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(optionsWithoutDb);
|
||||
const result = await client.executeSqlWithPg('SELECT 1');
|
||||
|
||||
expect(result).toHaveProperty('error');
|
||||
expect((result as { error: { message: string } }).error.message).toContain(
|
||||
'DATABASE_URL is not configured'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles database errors', async () => {
|
||||
const dbError = new Error('Connection refused') as Error & { code: string };
|
||||
dbError.code = 'ECONNREFUSED';
|
||||
mockPoolClient.query.mockImplementation(() => Promise.reject(dbError));
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
const result = await client.executeSqlWithPg('SELECT 1');
|
||||
|
||||
expect(result).toHaveProperty('error');
|
||||
expect((result as { error: { message: string } }).error.message).toContain(
|
||||
'Connection refused'
|
||||
);
|
||||
expect((result as { error: { code: string } }).error.code).toBe('ECONNREFUSED');
|
||||
});
|
||||
|
||||
test('releases client after successful query', async () => {
|
||||
mockPoolClient.query.mockImplementation(() =>
|
||||
Promise.resolve({ rows: [] })
|
||||
);
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
await client.executeSqlWithPg('SELECT 1');
|
||||
|
||||
expect(mockPoolClient.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('releases client after failed query', async () => {
|
||||
mockPoolClient.query.mockImplementation(() =>
|
||||
Promise.reject(new Error('Query failed'))
|
||||
);
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
await client.executeSqlWithPg('SELECT 1');
|
||||
|
||||
expect(mockPoolClient.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTransactionWithPg', () => {
|
||||
test('commits transaction on success', async () => {
|
||||
const expectedResult = { success: true };
|
||||
mockPoolClient.query.mockImplementation(() =>
|
||||
Promise.resolve({ rows: [] })
|
||||
);
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
const result = await client.executeTransactionWithPg(async (pgClient) => {
|
||||
await pgClient.query('INSERT INTO users (name) VALUES ($1)', ['test']);
|
||||
return expectedResult;
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
// Check that BEGIN was called
|
||||
expect(mockPoolClient.query).toHaveBeenCalledWith('BEGIN');
|
||||
// Check that COMMIT was called
|
||||
expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
|
||||
});
|
||||
|
||||
test('rolls back transaction on failure', async () => {
|
||||
let beginCalled = false;
|
||||
mockPoolClient.query.mockImplementation((query: string) => {
|
||||
if (query === 'BEGIN') {
|
||||
beginCalled = true;
|
||||
return Promise.resolve({ rows: [] });
|
||||
}
|
||||
if (query === 'ROLLBACK') {
|
||||
return Promise.resolve({ rows: [] });
|
||||
}
|
||||
if (query === 'COMMIT') {
|
||||
return Promise.resolve({ rows: [] });
|
||||
}
|
||||
// Fail on the actual operation
|
||||
return Promise.reject(new Error('Insert failed'));
|
||||
});
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
|
||||
await expect(
|
||||
client.executeTransactionWithPg(async (pgClient) => {
|
||||
await pgClient.query('INSERT INTO users (name) VALUES ($1)', ['test']);
|
||||
})
|
||||
).rejects.toThrow('Insert failed');
|
||||
|
||||
expect(beginCalled).toBe(true);
|
||||
expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK');
|
||||
});
|
||||
|
||||
test('throws error when databaseUrl is not configured', async () => {
|
||||
const optionsWithoutDb = {
|
||||
supabaseUrl: validOptions.supabaseUrl,
|
||||
supabaseAnonKey: validOptions.supabaseAnonKey,
|
||||
};
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(optionsWithoutDb);
|
||||
|
||||
await expect(
|
||||
client.executeTransactionWithPg(async () => {})
|
||||
).rejects.toThrow('DATABASE_URL is not configured');
|
||||
});
|
||||
|
||||
test('releases client after transaction', async () => {
|
||||
mockPoolClient.query.mockImplementation(() =>
|
||||
Promise.resolve({ rows: [] })
|
||||
);
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
await client.executeTransactionWithPg(async () => {});
|
||||
|
||||
expect(mockPoolClient.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('releases client after failed transaction', async () => {
|
||||
mockPoolClient.query.mockImplementation((query: string) => {
|
||||
if (query === 'BEGIN' || query === 'ROLLBACK') {
|
||||
return Promise.resolve({ rows: [] });
|
||||
}
|
||||
return Promise.reject(new Error('Failed'));
|
||||
});
|
||||
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
|
||||
try {
|
||||
await client.executeTransactionWithPg(async (pgClient) => {
|
||||
await pgClient.query('FAIL');
|
||||
});
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(mockPoolClient.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('supabase client access', () => {
|
||||
test('exposes supabase client instance', async () => {
|
||||
const client = await SelfhostedSupabaseClient.create(validOptions);
|
||||
expect(client.supabase).toBeDefined();
|
||||
expect(client.supabase).toBe(mockSupabaseClient);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user