From 8e77df51e8ad82479963365d92cdf80ab81a0ca2 Mon Sep 17 00:00:00 2001 From: Jennylila <270115372+Jennylila@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:32:22 +0100 Subject: [PATCH] Add Stellar DEX source integration tests with mocked Horizon Implement midpoint orderbook price derivation and cover all seven Horizon scenarios with Jest mocks so regressions in price logic are caught without network calls. --- src/services/sources/stellarDex.js | 81 ++++++++--------- test/stellarDex.test.js | 139 +++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 46 deletions(-) create mode 100644 test/stellarDex.test.js diff --git a/src/services/sources/stellarDex.js b/src/services/sources/stellarDex.js index 7ea535c..3503f7e 100644 --- a/src/services/sources/stellarDex.js +++ b/src/services/sources/stellarDex.js @@ -1,6 +1,5 @@ const { Server } = require('stellar-sdk'); const config = require('../../config'); -const logger = require('../../logger'); let server = null; @@ -13,60 +12,50 @@ function getServer() { const XLM_ASSET = { native: true }; -async function fetchPrice(assetCode, issuer) { - try { - const horizon = getServer(); - - let base; - let counter; - - if (!issuer || assetCode === 'XLM') { - base = XLM_ASSET; - counter = { code: 'USDC', issuer: config.stellar.usdcIssuer }; - } else { - base = { code: assetCode, issuer }; - counter = XLM_ASSET; - } - - const orderBook = await horizon.orderbook(base, counter === XLM_ASSET ? undefined : counter).limit(1).call(); +function derivePriceFromOrderbook(orderBook) { + const asks = orderBook.asks ?? []; + const bids = orderBook.bids ?? []; - if (!orderBook.bids || orderBook.bids.length === 0) { - return null; - } + const hasAsks = asks.length > 0; + const hasBids = bids.length > 0; - const bestBid = parseFloat(orderBook.bids[0].price); + if (!hasAsks && !hasBids) { + return null; + } - if (!issuer || assetCode === 'XLM') { - const xlmUsdcPrice = bestBid; - const xlmUsd = await getXlmUsdPrice(horizon); - if (xlmUsd === null) return null; - return xlmUsdcPrice * xlmUsd; - } + if (hasAsks && hasBids) { + const bestAsk = parseFloat(asks[0].price); + const bestBid = parseFloat(bids[0].price); + return (bestAsk + bestBid) / 2; + } - return bestBid; - } catch (err) { - logger.warn('Stellar DEX price fetch failed', { assetCode, issuer, error: err.message }); - return null; + if (hasBids) { + return parseFloat(bids[0].price); } + + return parseFloat(asks[0].price); } -async function getXlmUsdPrice(horizon) { - try { - const usdcIssuer = config.stellar.usdcIssuer; - const orderBook = await horizon - .orderbook(XLM_ASSET, { code: 'USDC', issuer: usdcIssuer }) - .limit(1) - .call(); +async function fetchPrice(assetCode, issuer) { + const horizon = getServer(); - if (!orderBook.bids || orderBook.bids.length === 0) { - return null; - } + let base; + let counter; - return parseFloat(orderBook.bids[0].price); - } catch (err) { - logger.warn('XLM/USDC price fetch failed', { error: err.message }); - return null; + if (!issuer || assetCode === 'XLM') { + base = XLM_ASSET; + counter = { code: 'USDC', issuer: config.stellar.usdcIssuer }; + } else { + base = { code: assetCode, issuer }; + counter = XLM_ASSET; } + + const orderBook = await horizon + .orderbook(base, counter === XLM_ASSET ? undefined : counter) + .limit(1) + .call(); + + return derivePriceFromOrderbook(orderBook); } -module.exports = { fetchPrice }; +module.exports = { fetchPrice, derivePriceFromOrderbook }; diff --git a/test/stellarDex.test.js b/test/stellarDex.test.js new file mode 100644 index 0000000..dc3edfe --- /dev/null +++ b/test/stellarDex.test.js @@ -0,0 +1,139 @@ +'use strict'; + +const mockCall = jest.fn(); +const mockLimit = jest.fn(() => ({ call: mockCall })); +const mockOrderbook = jest.fn(() => ({ limit: mockLimit })); +const MockServer = jest.fn().mockImplementation(() => ({ + orderbook: mockOrderbook, +})); + +const mockUsdcIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA'; + +jest.mock('stellar-sdk', () => ({ + Server: MockServer, +})); + +jest.mock('../src/config', () => ({ + stellar: { + horizonUrl: 'https://horizon.test', + usdcIssuer: mockUsdcIssuer, + }, +})); + +function loadStellarDex() { + jest.resetModules(); + mockCall.mockReset(); + mockLimit.mockClear(); + mockOrderbook.mockClear(); + MockServer.mockClear(); + return require('../src/services/sources/stellarDex'); +} + +function mockOrderbookResponse({ asks = [], bids = [] } = {}) { + mockCall.mockResolvedValueOnce({ asks, bids }); +} + +describe('Stellar DEX source', () => { + test('returns midpoint of best ask and best bid for a normal orderbook', async () => { + const stellarDex = loadStellarDex(); + mockOrderbookResponse({ + asks: [{ price: '0.12' }], + bids: [{ price: '0.11' }], + }); + + const price = await stellarDex.fetchPrice('USDC', mockUsdcIssuer); + + expect(price).toBeCloseTo(0.115, 10); + expect(mockOrderbook).toHaveBeenCalledWith( + { code: 'USDC', issuer: mockUsdcIssuer }, + undefined, + ); + expect(mockLimit).toHaveBeenCalledWith(1); + }); + + test('uses best bid only when asks array is empty', async () => { + const stellarDex = loadStellarDex(); + mockOrderbookResponse({ + asks: [], + bids: [{ price: '0.11' }], + }); + + const price = await stellarDex.fetchPrice('USDC', mockUsdcIssuer); + + expect(price).toBe(0.11); + }); + + test('uses best ask only when bids array is empty', async () => { + const stellarDex = loadStellarDex(); + mockOrderbookResponse({ + asks: [{ price: '0.12' }], + bids: [], + }); + + const price = await stellarDex.fetchPrice('USDC', mockUsdcIssuer); + + expect(price).toBe(0.12); + }); + + test('returns null gracefully when both asks and bids are empty', async () => { + const stellarDex = loadStellarDex(); + mockOrderbookResponse({ + asks: [], + bids: [], + }); + + const price = await stellarDex.fetchPrice('USDC', mockUsdcIssuer); + + expect(price).toBeNull(); + }); + + test('throws when Horizon returns a non-200 response', async () => { + const stellarDex = loadStellarDex(); + mockCall.mockRejectedValueOnce(new Error('Horizon request failed with status 404')); + + await expect(stellarDex.fetchPrice('USDC', mockUsdcIssuer)).rejects.toThrow( + 'Horizon request failed with status 404', + ); + }); + + test('throws with timeout message when Horizon times out', async () => { + const stellarDex = loadStellarDex(); + mockCall.mockRejectedValueOnce(new Error('Request timed out')); + + await expect(stellarDex.fetchPrice('USDC', mockUsdcIssuer)).rejects.toThrow( + 'Request timed out', + ); + }); + + test('uses native XLM orderbook pair when asset has no issuer', async () => { + const stellarDex = loadStellarDex(); + mockOrderbookResponse({ + asks: [{ price: '0.12' }], + bids: [{ price: '0.10' }], + }); + + const price = await stellarDex.fetchPrice('XLM'); + + expect(price).toBe(0.11); + expect(mockOrderbook).toHaveBeenCalledWith( + { native: true }, + { code: 'USDC', issuer: mockUsdcIssuer }, + ); + }); +}); + +describe('Stellar DEX source performance and coverage', () => { + test('completes mocked requests in under 100ms', async () => { + const stellarDex = loadStellarDex(); + mockOrderbookResponse({ + asks: [{ price: '0.12' }], + bids: [{ price: '0.11' }], + }); + + const start = Date.now(); + await stellarDex.fetchPrice('USDC', mockUsdcIssuer); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(100); + }); +});