Files
nullcart/backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts
T

246 lines
9.4 KiB
TypeScript

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);
});
});