- 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
444 lines
17 KiB
TypeScript
444 lines
17 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|