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; 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; let errorLogSpy: jest.SpiedFunction; let logSpy: jest.SpiedFunction; 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); }); });