Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
NODE_ENV=development
PORT=3001
LOG_LEVEL=debug

# Horizon API URL (Testnet by default)
HORIZON_URL=https://horizon-testnet.stellar.org
# Stellar Network Passphrase (Testnet by default)
STELLAR_NETWORK=Test SDF Network ; September 2015
# Horizon Request Timeout (ms)
HORIZON_TIMEOUT=30000
# Horizon Cache TTL (ms)
HORIZON_CACHE_TTL=30000
LOG_LEVEL=debug
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test": "NODE_ENV=test node --test dist/**/*.test.js"
},
"dependencies": {
"@stellar/stellar-sdk": "^16.0.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pino": "^8.16.0"
Expand Down
144 changes: 144 additions & 0 deletions backend/src/services/__tests__/horizon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { test, describe, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { HorizonService } from '../horizon.js';
import { HorizonError } from '../../utils/errors.js';

describe('HorizonService', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let horizonService: any;
// A valid cryptographically generated Stellar public key (ED25519)
const mockAddress = 'GDHVHQN2JFDZ5XYBIA3QBLGTHR7GXJZVUDTVQJJXM7SOMXA5YYBSDFWX';

beforeEach(() => {
horizonService = new HorizonService();
});

describe('validateAddress', () => {
test('should throw error for invalid Stellar address', async () => {
await assert.rejects(
() => horizonService.getAccountDetails('invalid-address'),
{
code: 'INVALID_ADDRESS',
statusCode: 400,
}
);
});
});

describe('getAccountDetails', () => {
test('should fetch and map account details correctly', async () => {
const mockAccount = {
id: mockAddress,
sequenceNumber: () => '12345',
balances: [{ asset_type: 'native', balance: '100.0' }],
signers: [{ key: mockAddress, weight: 1, type: 'ed25519_public_key' }],
flags: {
auth_required: false,
auth_revocable: false,
auth_immutable: false,
auth_clawback_enabled: false,
},
};

// Mock loadAccount
mock.method(horizonService.server, 'loadAccount', async () => mockAccount);

const details = await horizonService.getAccountDetails(mockAddress);

assert.equal(details.id, mockAddress);
assert.equal(details.sequence, '12345');
assert.equal(details.balances[0].balance, '100.0');
});

test('should handle 404 account not found', async () => {
const error404 = {
response: { status: 404, data: { detail: 'Not Found' } },
};

mock.method(horizonService.server, 'loadAccount', async () => {
throw error404;
});

await assert.rejects(
() => horizonService.getAccountDetails(mockAddress),
(err: HorizonError) => {
assert.ok(err instanceof HorizonError);
assert.equal(err.statusCode, 404);
assert.equal(err.code, 'ACCOUNT_NOT_FOUND');
return true;
}
);
});

test('should use cache for subsequent calls', async () => {
const mockAccount = {
id: mockAddress,
sequenceNumber: () => '12345',
balances: [],
signers: [],
flags: {
auth_required: false,
auth_revocable: false,
auth_immutable: false,
auth_clawback_enabled: false,
},
};

const loadAccountMock = mock.method(horizonService.server, 'loadAccount', async () => mockAccount);

await horizonService.getAccountDetails(mockAddress);
await horizonService.getAccountDetails(mockAddress);

assert.equal(loadAccountMock.mock.callCount(), 1);
});
});

describe('resilience', () => {
test('should retry on 429 rate limit error with jitter', async () => {
let attempts = 0;
const error429 = {
response: { status: 429, data: { detail: 'Rate limit' } },
};

const fn = async () => {
attempts++;
if (attempts < 2) throw error429;
return { success: true };
};

// Use small delay for tests
const result = await horizonService.withRetry(fn, 2, 10);
assert.equal(attempts, 2);
assert.ok(result.success);
});

test('should open circuit breaker after repeated failures', async () => {
const error500 = {
response: { status: 500, data: { detail: 'Server Error' } },
};

mock.method(horizonService.server, 'loadAccount', async () => {
throw error500;
});

// Trigger threshold (5) with retries disabled for speed
for (let i = 0; i < 5; i++) {
try {
await horizonService.getAccountDetails(mockAddress, { retries: 0 });
} catch {
// expected
}
}

// Next call should be blocked by circuit breaker
await assert.rejects(
() => horizonService.getAccountDetails(mockAddress),
(err: HorizonError) => {
assert.equal(err.statusCode, 503);
assert.equal(err.code, 'SERVICE_UNAVAILABLE');
return true;
}
);
});
});
});
Loading
Loading