From ad4ff18a7ceda04141b1879405e86875bf938fd4 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Fri, 19 Feb 2021 14:52:15 -0330 Subject: [PATCH] Extract CryptoCompare API (#353) The CryptoCompare API interactions have been extracted from the CurrencyRateController and moved into a separate module. --- src/apis/crypto-compare.test.ts | 137 ++++++++++++++++++++++ src/apis/crypto-compare.ts | 35 ++++++ src/assets/CurrencyRateController.test.ts | 119 +++++++------------ src/assets/CurrencyRateController.ts | 44 +------ 4 files changed, 221 insertions(+), 114 deletions(-) create mode 100644 src/apis/crypto-compare.test.ts create mode 100644 src/apis/crypto-compare.ts diff --git a/src/apis/crypto-compare.test.ts b/src/apis/crypto-compare.test.ts new file mode 100644 index 00000000000..780a71e95fd --- /dev/null +++ b/src/apis/crypto-compare.test.ts @@ -0,0 +1,137 @@ +import * as nock from 'nock'; + +import { fetchExchangeRate } from './crypto-compare'; + +const cryptoCompareHost = 'https://min-api.cryptocompare.com'; + +describe('CryptoCompare', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.enableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should return CAD conversion rate', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 2000.42 }); + + const { conversionRate } = await fetchExchangeRate('CAD', 'ETH'); + + expect(conversionRate).toEqual(2000.42); + }); + + it('should return conversion date', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 2000.42 }); + + const before = Date.now() / 1000; + const { conversionDate } = await fetchExchangeRate('CAD', 'ETH'); + const after = Date.now() / 1000; + + expect(conversionDate).toBeGreaterThanOrEqual(before); + expect(conversionDate).toBeLessThanOrEqual(after); + }); + + it('should return CAD conversion rate given lower-cased currency', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 2000.42 }); + + const { conversionRate } = await fetchExchangeRate('cad', 'ETH'); + + expect(conversionRate).toEqual(2000.42); + }); + + it('should return CAD conversion rate given lower-cased native currency', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 2000.42 }); + + const { conversionRate } = await fetchExchangeRate('CAD', 'eth'); + + expect(conversionRate).toEqual(2000.42); + }); + + it('should not return USD conversion rate when fetching just CAD conversion rate', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 1000.42 }); + + const { usdConversionRate } = await fetchExchangeRate('CAD', 'ETH'); + + expect(usdConversionRate).toBeFalsy(); + }); + + it('should return USD conversion rate for USD even when includeUSD is disabled', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=USD') + .reply(200, { USD: 1000.42 }); + + const { conversionRate, usdConversionRate } = await fetchExchangeRate('USD', 'ETH', false); + + expect(conversionRate).toEqual(1000.42); + expect(usdConversionRate).toEqual(1000.42); + }); + + it('should return USD conversion rate for USD when includeUSD is enabled', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=USD') + .reply(200, { USD: 1000.42 }); + + const { conversionRate, usdConversionRate } = await fetchExchangeRate('USD', 'ETH', true); + + expect(conversionRate).toEqual(1000.42); + expect(usdConversionRate).toEqual(1000.42); + }); + + it('should return CAD and USD conversion rate', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD,USD') + .reply(200, { CAD: 2000.42, USD: 1000.42 }); + + const { conversionRate, usdConversionRate } = await fetchExchangeRate('CAD', 'ETH', true); + + expect(conversionRate).toEqual(2000.42); + expect(usdConversionRate).toEqual(1000.42); + }); + + it('should throw if fetch throws', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .replyWithError('Example network error'); + + await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow('Example network error'); + }); + + it('should throw if fetch returns unsuccessful response', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(500); + + await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow(`Fetch failed with status '500' for request '${cryptoCompareHost}/data/price?fsym=ETH&tsyms=CAD'`); + }); + + it('should throw if conversion rate is invalid', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 'invalid' }); + + await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow('Invalid response for CAD: invalid'); + }); + + it('should throw if USD conversion rate is invalid', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD,USD') + .reply(200, { CAD: 2000.47, USD: 'invalid' }); + + await expect(fetchExchangeRate('CAD', 'ETH', true)).rejects.toThrow('Invalid response for usdConversionRate: invalid'); + }); +}); diff --git a/src/apis/crypto-compare.ts b/src/apis/crypto-compare.ts new file mode 100644 index 00000000000..1e13c359f35 --- /dev/null +++ b/src/apis/crypto-compare.ts @@ -0,0 +1,35 @@ +import { handleFetch } from '../util'; + +function getPricingURL(currentCurrency: string, nativeCurrency: string, includeUSDRate?: boolean) { + return ( + `https://min-api.cryptocompare.com/data/price?fsym=` + + `${nativeCurrency.toUpperCase()}&tsyms=${currentCurrency.toUpperCase()}` + + `${includeUSDRate && currentCurrency.toUpperCase() !== 'USD' ? ',USD' : ''}` + ); +} + + /** + * Fetches the exchange rate for a given currency + * + * @param currency - ISO 4217 currency code + * @param nativeCurrency - Symbol for base asset + * @param includeUSDRate - Whether to add the USD rate to the fetch + * @returns - Promise resolving to exchange rate for given currency + */ +export async function fetchExchangeRate(currency: string, nativeCurrency: string, includeUSDRate?: boolean): Promise<{ conversionDate: number; conversionRate: number; usdConversionRate: number }> { + const json = await handleFetch(getPricingURL(currency, nativeCurrency, includeUSDRate)); + const conversionRate = Number(json[currency.toUpperCase()]); + const usdConversionRate = Number(json.USD); + if (!Number.isFinite(conversionRate)) { + throw new Error(`Invalid response for ${currency.toUpperCase()}: ${json[currency.toUpperCase()]}`); + } + if (includeUSDRate && !Number.isFinite(usdConversionRate)) { + throw new Error(`Invalid response for usdConversionRate: ${json.USD}`); + } + + return { + conversionDate: Date.now() / 1000, + conversionRate, + usdConversionRate, + }; +} diff --git a/src/assets/CurrencyRateController.test.ts b/src/assets/CurrencyRateController.test.ts index 38de953bfbd..07b422f9a35 100644 --- a/src/assets/CurrencyRateController.test.ts +++ b/src/assets/CurrencyRateController.test.ts @@ -1,26 +1,11 @@ import 'isomorphic-fetch'; import { stub } from 'sinon'; -import * as nock from 'nock'; import CurrencyRateController from './CurrencyRateController'; describe('CurrencyRateController', () => { - beforeEach(() => { - nock(/.+/u) - .get(/XYZ,USD/u) - .reply(200, { XYZ: 123, USD: 456 }) - .get(/DEF,USD/u) - .reply(200, { DEF: 123 }) - .get(/.+/u) - .reply(200, { USD: 1337 }) - .persist(); - }); - - afterEach(() => { - nock.cleanAll(); - }); - it('should set default state', () => { - const controller = new CurrencyRateController(); + const fetchExchangeRateStub = stub(); + const controller = new CurrencyRateController({}, {}, fetchExchangeRateStub); expect(controller.state).toEqual({ conversionDate: 0, conversionRate: 0, @@ -28,10 +13,13 @@ describe('CurrencyRateController', () => { nativeCurrency: 'ETH', usdConversionRate: 0, }); + + controller.disabled = true; }); it('should initialize with the default config', () => { - const controller = new CurrencyRateController(); + const fetchExchangeRateStub = stub(); + const controller = new CurrencyRateController({}, {}, fetchExchangeRateStub); expect(controller.config).toEqual({ currentCurrency: 'usd', disabled: false, @@ -39,11 +27,14 @@ describe('CurrencyRateController', () => { nativeCurrency: 'ETH', includeUSDRate: false, }); + + controller.disabled = true; }); it('should initialize with the currency in state', () => { + const fetchExchangeRateStub = stub(); const existingState = { currentCurrency: 'rep' }; - const controller = new CurrencyRateController({}, existingState); + const controller = new CurrencyRateController({}, existingState, fetchExchangeRateStub); expect(controller.config).toEqual({ currentCurrency: 'rep', disabled: false, @@ -51,92 +42,68 @@ describe('CurrencyRateController', () => { nativeCurrency: 'ETH', includeUSDRate: false, }); + + controller.disabled = true; }); - it('should poll and update rate in the right interval', () => { - return new Promise((resolve) => { - const controller = new CurrencyRateController({ interval: 100 }); - const mock = stub(controller, 'fetchExchangeRate'); - setTimeout(() => { - expect(mock.called).toBe(true); - expect(mock.calledTwice).toBe(false); - }, 1); - setTimeout(() => { - expect(mock.calledTwice).toBe(true); - mock.restore(); - resolve(); - }, 150); - }); + it('should poll and update rate in the right interval', async () => { + const fetchExchangeRateStub = stub(); + const controller = new CurrencyRateController({ interval: 100 }, {}, fetchExchangeRateStub); + + await new Promise((resolve) => setTimeout(() => resolve(), 1)); + expect(fetchExchangeRateStub.called).toBe(true); + expect(fetchExchangeRateStub.calledTwice).toBe(false); + await new Promise((resolve) => setTimeout(() => resolve(), 150)); + expect(fetchExchangeRateStub.calledTwice).toBe(true); + + controller.disabled = true; }); it('should not update rates if disabled', async () => { - const controller = new CurrencyRateController({ - interval: 10, - }); - controller.fetchExchangeRate = stub().resolves({}); + const fetchExchangeRateStub = stub().resolves({}); + const controller = new CurrencyRateController({ interval: 10 }, {}, fetchExchangeRateStub); controller.disabled = true; + await controller.updateExchangeRate(); - expect((controller.fetchExchangeRate as any).called).toBe(false); + expect(fetchExchangeRateStub.called).toBe(false); }); it('should clear previous interval', () => { + const fetchExchangeRateStub = stub(); const mock = stub(global, 'clearTimeout'); - const controller = new CurrencyRateController({ interval: 1337 }); + const controller = new CurrencyRateController({ interval: 1337 }, {}, fetchExchangeRateStub); return new Promise((resolve) => { setTimeout(() => { controller.poll(1338); expect(mock.called).toBe(true); mock.restore(); + + controller.disabled = true; resolve(); }, 100); }); }); it('should update currency', async () => { - const controller = new CurrencyRateController({ interval: 10 }); + const fetchExchangeRateStub = stub().resolves({ conversionRate: 10 }); + const controller = new CurrencyRateController({ interval: 10 }, {}, fetchExchangeRateStub); expect(controller.state.conversionRate).toEqual(0); await controller.updateExchangeRate(); - expect(controller.state.conversionRate).toBeGreaterThan(0); - }); - - it('should add usd rate to state when includeUSDRate is configured true', async () => { - const controller = new CurrencyRateController({ includeUSDRate: true, currentCurrency: 'xyz' }); - expect(controller.state.usdConversionRate).toEqual(0); - await controller.updateExchangeRate(); - expect(controller.state.usdConversionRate).toEqual(456); - }); + expect(controller.state.conversionRate).toEqual(10); - it('should add usd rate to state fetches when configured', async () => { - const controller = new CurrencyRateController({ includeUSDRate: true }); - const result = await controller.fetchExchangeRate('xyz', 'FOO', true); - expect(result.usdConversionRate).toEqual(456); - expect(result.conversionRate).toEqual(123); + controller.disabled = true; }); - it('should throw correctly when configured to return usd but receives an invalid response for currentCurrency rate', async () => { - const controller = new CurrencyRateController({ includeUSDRate: true }); - await expect(controller.fetchExchangeRate('abc', 'FOO', true)).rejects.toThrow( - 'Invalid response for ABC: undefined', + it('should add usd rate to state when includeUSDRate is configured true', async () => { + const fetchExchangeRateStub = stub().resolves({}); + const controller = new CurrencyRateController( + { includeUSDRate: true, currentCurrency: 'xyz' }, + {}, + fetchExchangeRateStub, ); - }); - it('should throw correctly when configured to return usd but receives an invalid response for usdConversionRate', async () => { - const controller = new CurrencyRateController({ includeUSDRate: true }); - await expect(controller.fetchExchangeRate('def', 'FOO', true)).rejects.toThrow( - 'Invalid response for usdConversionRate: undefined', - ); - }); - - describe('#fetchExchangeRate', () => { - it('should handle a valid symbol in the API response', async () => { - const controller = new CurrencyRateController({ nativeCurrency: 'usd' }); - const response = await controller.fetchExchangeRate('usd'); - expect(response.conversionRate).toEqual(1337); - }); + await controller.updateExchangeRate(); - it('should handle a missing symbol in the API response', async () => { - const controller = new CurrencyRateController({ nativeCurrency: 'usd' }); - await expect(controller.fetchExchangeRate('foo')).rejects.toThrow('Invalid response for FOO: undefined'); - }); + expect(fetchExchangeRateStub.alwaysCalledWithExactly('xyz', 'ETH', true)).toBe(true); }); }); diff --git a/src/assets/CurrencyRateController.ts b/src/assets/CurrencyRateController.ts index 556dedda6d9..b64cd91b67d 100644 --- a/src/assets/CurrencyRateController.ts +++ b/src/assets/CurrencyRateController.ts @@ -1,5 +1,6 @@ import BaseController, { BaseConfig, BaseState } from '../BaseController'; -import { safelyExecute, handleFetch } from '../util'; +import { safelyExecute } from '../util'; +import { fetchExchangeRate as defaultFetchExchangeRate } from '../apis/crypto-compare'; const { Mutex } = require('async-mutex'); @@ -55,18 +56,12 @@ export class CurrencyRateController extends BaseController) { return state && state.currentCurrency ? state.currentCurrency : 'usd'; } - private getPricingURL(currentCurrency: string, nativeCurrency: string, includeUSDRate?: boolean) { - return ( - `https://min-api.cryptocompare.com/data/price?fsym=` + - `${nativeCurrency.toUpperCase()}&tsyms=${currentCurrency.toUpperCase()}` + - `${includeUSDRate && currentCurrency.toUpperCase() !== 'USD' ? ',USD' : ''}` - ); - } - /** * Name of this controller used during composition */ @@ -78,8 +73,9 @@ export class CurrencyRateController extends BaseController, state?: Partial) { + constructor(config?: Partial, state?: Partial, fetchExchangeRate = defaultFetchExchangeRate) { super(config, state); + this.fetchExchangeRate = fetchExchangeRate; this.defaultConfig = { currentCurrency: this.getCurrentCurrencyFromState(state), disabled: true, @@ -133,34 +129,6 @@ export class CurrencyRateController extends BaseController { - const json = await handleFetch(this.getPricingURL(currency, nativeCurrency, includeUSDRate)); - const conversionRate = Number(json[currency.toUpperCase()]); - const usdConversionRate = Number(json.USD); - if (!Number.isFinite(conversionRate)) { - throw new Error(`Invalid response for ${currency.toUpperCase()}: ${json[currency.toUpperCase()]}`); - } - if (includeUSDRate && !Number.isFinite(usdConversionRate)) { - throw new Error(`Invalid response for usdConversionRate: ${json.USD}`); - } - - return { - conversionDate: Date.now() / 1000, - conversionRate, - currentCurrency: currency, - nativeCurrency, - usdConversionRate, - }; - } - /** * Updates exchange rate for the current currency *