refactor xmr rate module to exchange rate module for multi crypto support
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
import { ExchangeRateService } from './ExchangeRateService';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
const buildCoinPaprikaTicker = (price: number, fiat = 'USD') => ({
|
||||
quotes: { [fiat]: { price } }
|
||||
});
|
||||
|
||||
const buildCoinPaprikaAxiosMock =
|
||||
(xmrPrice: number, btcPrice: number, fiat = 'USD') =>
|
||||
(url: string) => {
|
||||
if (url === `https://coinpaprika.test/v1/tickers/xmr-monero?quotes=${fiat}`) {
|
||||
return Promise.resolve({ data: buildCoinPaprikaTicker(xmrPrice, fiat) });
|
||||
}
|
||||
|
||||
if (url === `https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=${fiat}`) {
|
||||
return Promise.resolve({ data: buildCoinPaprikaTicker(btcPrice, fiat) });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`unexpected url: ${url}`));
|
||||
};
|
||||
|
||||
describe('ExchangeRateService', () => {
|
||||
let service: ExchangeRateService;
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
let warnLogSpy: jest.SpiedFunction<typeof Logger.prototype.warn>;
|
||||
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
|
||||
configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'shopSettings') {
|
||||
return { shopFiatCurrency: 'USD' };
|
||||
}
|
||||
|
||||
if (key === 'coingecko') {
|
||||
return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
|
||||
}
|
||||
|
||||
if (key === 'coinPaprika') {
|
||||
return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
};
|
||||
|
||||
service = new ExchangeRateService(configService as unknown as ConfigService);
|
||||
mockedAxios.get.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnLogSpy.mockRestore();
|
||||
errorLogSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores batched CoinGecko rates when the fetch succeeds', async () => {
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
monero: { usd: 152.3456 },
|
||||
bitcoin: { usd: 95_432.1987 }
|
||||
}
|
||||
});
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=usd',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(152.35);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_432.2);
|
||||
});
|
||||
|
||||
it('falls back to CoinPaprika when CoinGecko fails', async () => {
|
||||
mockedAxios.get
|
||||
.mockRejectedValueOnce(new Error('coingecko down'))
|
||||
.mockImplementation(buildCoinPaprikaAxiosMock(149.876, 94_999.5));
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(warnLogSpy).toHaveBeenCalled();
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.88);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_999.5);
|
||||
});
|
||||
|
||||
it('leaves cached rates null when both providers fail on the first fetch', async () => {
|
||||
mockedAxios.get
|
||||
.mockRejectedValueOnce(new Error('coingecko down'))
|
||||
.mockRejectedValue(new Error('coinpaprika down'));
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalled();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to CoinPaprika when CoinGecko returns an invalid payload', async () => {
|
||||
mockedAxios.get
|
||||
.mockResolvedValueOnce({ data: { monero: { usd: -1 }, bitcoin: { usd: 95_000 } } })
|
||||
.mockImplementation(buildCoinPaprikaAxiosMock(151.11, 95_111.11));
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(warnLogSpy).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith('Successfully fetched live rates from CoinPaprika for USD');
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(3);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(151.11);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_111.11);
|
||||
});
|
||||
|
||||
it('falls back to CoinPaprika when CoinGecko omits a coin', async () => {
|
||||
mockedAxios.get
|
||||
.mockResolvedValueOnce({ data: { monero: { usd: 150 } } })
|
||||
.mockImplementation(buildCoinPaprikaAxiosMock(149.5, 94_000));
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(warnLogSpy).toHaveBeenCalled();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.5);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_000);
|
||||
});
|
||||
|
||||
it('leaves cached rates null when CoinPaprika returns an invalid payload for one coin', async () => {
|
||||
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
|
||||
if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
|
||||
return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
|
||||
}
|
||||
|
||||
if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
|
||||
return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`unexpected url: ${url}`));
|
||||
});
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalled();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the previous live rates when a refresh fails after a successful fetch', async () => {
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
monero: { usd: 140 },
|
||||
bitcoin: { usd: 90_000 }
|
||||
}
|
||||
});
|
||||
await service.fetchLiveRates();
|
||||
|
||||
mockedAxios.get
|
||||
.mockRejectedValueOnce(new Error('coingecko down'))
|
||||
.mockRejectedValue(new Error('coinpaprika down'));
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
|
||||
});
|
||||
|
||||
it('keeps the previous live rates when CoinPaprika returns an invalid payload for one coin on refresh', async () => {
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
monero: { usd: 140 },
|
||||
bitcoin: { usd: 90_000 }
|
||||
}
|
||||
});
|
||||
await service.fetchLiveRates();
|
||||
|
||||
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
|
||||
if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
|
||||
return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
|
||||
}
|
||||
|
||||
if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
|
||||
return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`unexpected url: ${url}`));
|
||||
});
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalled();
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
|
||||
});
|
||||
|
||||
it('uses the shop fiat currency when building provider URLs', async () => {
|
||||
configService.get.mockImplementation((key: string) => {
|
||||
if (key === 'shopSettings') {
|
||||
return { shopFiatCurrency: 'EUR' };
|
||||
}
|
||||
|
||||
if (key === 'coingecko') {
|
||||
return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
|
||||
}
|
||||
|
||||
if (key === 'coinPaprika') {
|
||||
return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
monero: { eur: 130.5 },
|
||||
bitcoin: { eur: 88_000.4 }
|
||||
}
|
||||
});
|
||||
|
||||
await service.fetchLiveRates();
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=eur',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(130.5);
|
||||
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(88_000.4);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user