Skip to content

Commit

Permalink
Extract CryptoCompare API (#353)
Browse files Browse the repository at this point in the history
The CryptoCompare API interactions have been extracted from the
CurrencyRateController and moved into a separate module.
  • Loading branch information
Gudahtt authored and MajorLift committed Oct 11, 2023
1 parent 09211b6 commit ad4ff18
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 114 deletions.
137 changes: 137 additions & 0 deletions src/apis/crypto-compare.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
35 changes: 35 additions & 0 deletions src/apis/crypto-compare.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
119 changes: 43 additions & 76 deletions src/assets/CurrencyRateController.test.ts
Original file line number Diff line number Diff line change
@@ -1,142 +1,109 @@
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,
currentCurrency: 'usd',
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,
interval: 180000,
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,
interval: 180000,
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);
});
});
Loading

0 comments on commit ad4ff18

Please sign in to comment.