@openzeppelin/contracts-ui-builder-adapter-stellar
Version:
Stellar Adapter for Contracts UI Builder
358 lines (297 loc) • 12.7 kB
text/typescript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { StellarNetworkConfig } from '@openzeppelin/contracts-ui-builder-types';
import { appConfigService } from '@openzeppelin/contracts-ui-builder-utils';
import { resolveRpcUrl, testStellarRpcConnection, validateStellarRpcEndpoint } from '../rpc';
// Helper to create a mock StellarNetworkConfig
const createMockConfig = (
id: string,
sorobanRpcUrl?: string,
name?: string
): StellarNetworkConfig => ({
id,
name: name || `Test ${id}`,
ecosystem: 'stellar',
network: 'stellar',
type: 'testnet',
isTestnet: true,
exportConstName: id.replace(/-/g, ''),
horizonUrl: `https://horizon-${id}.stellar.org`,
sorobanRpcUrl: sorobanRpcUrl || '', // Allow undefined or empty for testing error cases
networkPassphrase: `Test ${id} Network`,
explorerUrl: `https://stellar.expert/explorer/${id}`,
icon: 'stellar',
});
// Mock the appConfigService from the correct package
vi.mock('@openzeppelin/contracts-ui-builder-utils', async (importOriginal) => {
const original =
await importOriginal<typeof import('@openzeppelin/contracts-ui-builder-utils')>();
return {
...original,
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
appConfigService: {
getRpcEndpointOverride: vi.fn(),
getConfig: vi.fn().mockReturnValue({ rpcEndpoints: {} }),
},
};
});
describe('resolveRpcUrl', () => {
beforeEach(() => {
// Reset mocks before each test to ensure test isolation
vi.mocked(appConfigService.getRpcEndpointOverride).mockReset();
vi.mocked(appConfigService.getConfig).mockReturnValue({ rpcEndpoints: {} });
});
it('should use RPC override from AppConfigService if available (string)', () => {
const networkId = 'stellar-testnet-override';
const overrideRpcUrl = 'https://custom-soroban-rpc.example.com';
vi.mocked(appConfigService.getRpcEndpointOverride).mockReturnValue(overrideRpcUrl);
const config = createMockConfig(networkId, 'https://soroban-testnet.stellar.org');
expect(resolveRpcUrl(config)).toBe(overrideRpcUrl);
});
it('should use RPC override from AppConfigService if available (object with http)', () => {
const networkId = 'stellar-testnet-object-override';
const overrideRpcUrl = 'https://custom-soroban-object.example.com';
vi.mocked(appConfigService.getRpcEndpointOverride).mockReturnValue({ http: overrideRpcUrl });
const config = createMockConfig(networkId, 'https://soroban-testnet.stellar.org');
expect(resolveRpcUrl(config)).toBe(overrideRpcUrl);
});
it('should fall back to networkConfig.sorobanRpcUrl if no override is available', () => {
const networkId = 'stellar-testnet-fallback';
const defaultRpcUrl = 'https://soroban-testnet.stellar.org';
vi.mocked(appConfigService.getRpcEndpointOverride).mockReturnValue(undefined);
const config = createMockConfig(networkId, defaultRpcUrl);
expect(resolveRpcUrl(config)).toBe(defaultRpcUrl);
});
it('should fall back to networkConfig.sorobanRpcUrl if override is invalid URL', () => {
const networkId = 'stellar-testnet-invalid-override';
const defaultRpcUrl = 'https://soroban-testnet.stellar.org';
vi.mocked(appConfigService.getRpcEndpointOverride).mockReturnValue('invalid-url');
const config = createMockConfig(networkId, defaultRpcUrl);
expect(resolveRpcUrl(config)).toBe(defaultRpcUrl);
});
it('should throw an error if no valid Soroban RPC URL (override or default) is found', () => {
const networkId = 'no-valid-soroban-rpc';
vi.mocked(appConfigService.getRpcEndpointOverride).mockReturnValue(undefined);
const config = createMockConfig(networkId, undefined);
expect(() => resolveRpcUrl(config)).toThrowError(
`No valid Soroban RPC URL configured for network Test ${networkId} (ID: ${networkId}).`
);
});
it('should throw an error if default Soroban RPC is invalid and no override is found', () => {
const networkId = 'invalid-default-soroban-rpc';
vi.mocked(appConfigService.getRpcEndpointOverride).mockReturnValue(undefined);
const config = createMockConfig(networkId, 'not-a-url');
expect(() => resolveRpcUrl(config)).toThrowError(
`No valid Soroban RPC URL configured for network Test ${networkId} (ID: ${networkId}).`
);
});
it('should use override even if default is invalid', () => {
const networkId = 'override-wins-over-invalid-default';
const overrideRpcUrl = 'https://good-soroban-override.example.com';
vi.mocked(appConfigService.getRpcEndpointOverride).mockReturnValue(overrideRpcUrl);
const config = createMockConfig(networkId, 'bad-default-url');
expect(resolveRpcUrl(config)).toBe(overrideRpcUrl);
});
it('should work with mainnet Soroban RPC URL', () => {
const networkId = 'stellar-mainnet';
const mainnetRpcUrl = 'https://mainnet.sorobanrpc.com';
vi.mocked(appConfigService.getRpcEndpointOverride).mockReturnValue(undefined);
const config = createMockConfig(networkId, mainnetRpcUrl);
expect(resolveRpcUrl(config)).toBe(mainnetRpcUrl);
});
});
describe('validateStellarRpcEndpoint', () => {
it('should return true for valid Soroban RPC URLs', () => {
const validConfigs = [
{ url: 'https://soroban-testnet.stellar.org', name: 'Official Testnet', isCustom: false },
{ url: 'https://mainnet.sorobanrpc.com', name: 'Official Mainnet', isCustom: false },
{ url: 'https://stellar.publicnode.com', name: 'Public Node', isCustom: false },
{ url: 'https://custom-soroban.example.com', name: 'Custom', isCustom: true },
];
validConfigs.forEach((config) => {
expect(validateStellarRpcEndpoint(config)).toBe(true);
});
});
it('should return false for invalid URLs', () => {
const invalidConfigs = [
{ url: 'not-a-url', name: 'Invalid', isCustom: false },
{ url: '', name: 'Empty URL', isCustom: false },
];
invalidConfigs.forEach((config) => {
expect(validateStellarRpcEndpoint(config)).toBe(false);
});
});
it('should return true for HTTP localhost URLs (development)', () => {
const devConfig = {
url: 'http://localhost:8000/soroban/rpc',
name: 'Local Dev',
isCustom: true,
};
expect(validateStellarRpcEndpoint(devConfig)).toBe(true);
});
it('should handle validation errors gracefully', () => {
// This test verifies the catch block works, but doMock doesn't work in the middle of test execution
// Instead, we'll test that the function returns false on invalid input
const config = { url: null as unknown as string, name: 'Test', isCustom: false };
expect(validateStellarRpcEndpoint(config)).toBe(false);
});
});
describe('testStellarRpcConnection', () => {
beforeEach(() => {
// Reset fetch mock before each test
global.fetch = vi.fn();
vi.clearAllTimers();
vi.useFakeTimers();
// Mock Date.now to simulate time passage
const mockDate = new Date('2023-01-01T00:00:00Z');
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should return success for healthy Soroban RPC endpoint', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
jsonrpc: '2.0',
id: 1,
result: { status: 'healthy' },
}),
};
// Mock fetch to simulate some latency
vi.mocked(global.fetch).mockImplementation(async () => {
// Advance time to simulate network latency
vi.advanceTimersByTime(100);
return mockResponse as unknown as Response;
});
const config = { url: 'https://soroban-testnet.stellar.org', name: 'Testnet', isCustom: false };
const result = await testStellarRpcConnection(config);
expect(result.success).toBe(true);
expect(result.latency).toBeGreaterThan(0);
expect(result.error).toBeUndefined();
});
it('should return success for valid getLatestLedger response (fallback)', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
jsonrpc: '2.0',
id: 1,
result: { sequence: 12345, hash: 'abc123' },
}),
};
// Mock fetch to simulate some latency
vi.mocked(global.fetch).mockImplementation(async () => {
// Advance time to simulate network latency
vi.advanceTimersByTime(150);
return mockResponse as unknown as Response;
});
const config = { url: 'https://soroban-testnet.stellar.org', name: 'Testnet', isCustom: false };
const result = await testStellarRpcConnection(config);
expect(result.success).toBe(true);
expect(result.latency).toBeGreaterThan(0);
});
it('should return failure for HTTP error responses', async () => {
const mockResponse = {
ok: false,
status: 500,
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as unknown as Response);
const config = { url: 'https://bad-soroban.example.com', name: 'Bad RPC', isCustom: true };
const result = await testStellarRpcConnection(config);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP error: 500');
});
it('should return failure for JSON-RPC error responses', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
jsonrpc: '2.0',
id: 1,
error: { code: -32601, message: 'Method not found' },
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as unknown as Response);
const config = { url: 'https://soroban-testnet.stellar.org', name: 'Testnet', isCustom: false };
const result = await testStellarRpcConnection(config);
expect(result.success).toBe(false);
expect(result.error).toContain('Method not found');
});
it('should return failure for unhealthy service status', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
jsonrpc: '2.0',
id: 1,
result: { status: 'unhealthy' },
}),
};
// Mock fetch to simulate some latency
vi.mocked(global.fetch).mockImplementation(async () => {
// Advance time to simulate network latency
vi.advanceTimersByTime(120);
return mockResponse as unknown as Response;
});
const config = { url: 'https://soroban-testnet.stellar.org', name: 'Testnet', isCustom: false };
const result = await testStellarRpcConnection(config);
expect(result.success).toBe(false);
expect(result.error).toContain('unhealthy');
expect(result.latency).toBeGreaterThan(0);
});
it('should handle connection timeout', async () => {
// Create a mock that immediately rejects with AbortError
vi.mocked(global.fetch).mockRejectedValue(
(() => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
return error;
})()
);
const config = { url: 'https://slow-soroban.example.com', name: 'Slow RPC', isCustom: true };
const result = await testStellarRpcConnection(config, 1000);
expect(result.success).toBe(false);
expect(result.error).toContain('timeout');
});
it('should return failure for missing URL', async () => {
const config = { url: '', name: 'Empty URL', isCustom: false };
const result = await testStellarRpcConnection(config);
expect(result.success).toBe(false);
expect(result.error).toBe('Soroban RPC URL is required');
});
it('should handle network errors gracefully', async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
const config = {
url: 'https://unreachable-soroban.example.com',
name: 'Unreachable',
isCustom: true,
};
const result = await testStellarRpcConnection(config);
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
});
it('should use custom timeout value', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
jsonrpc: '2.0',
id: 1,
result: { status: 'healthy' },
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as unknown as Response);
const config = { url: 'https://soroban-testnet.stellar.org', name: 'Testnet', isCustom: false };
const result = await testStellarRpcConnection(config, 10000); // 10 second timeout
expect(result.success).toBe(true);
// Verify fetch was called with the signal
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
signal: expect.any(AbortSignal),
})
);
});
});