diff --git a/.gitignore b/.gitignore index 09f14ca..493bc2e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ free-bch-api-test.sh start-local-mainnet.sh.save start-local-testnet.sh.save temp.sh +test-fullstack-test.sh coverage start-my-infra diff --git a/src/app.js b/src/app.js index 2781d0b..a69a67b 100644 --- a/src/app.js +++ b/src/app.js @@ -39,6 +39,7 @@ const BlockbookV3 = require('./routes/v3/blockbook') const Ninsight = require('./routes/v3/ninsight') const ElectrumXV3 = require('./routes/v3/electrumx') const EncryptionV3 = require('./routes/v3/encryption') +const PriceV3 = require('./routes/v3/price') require('dotenv').config() @@ -52,6 +53,7 @@ const blockbookV3 = new BlockbookV3() const electrumxv3 = new ElectrumXV3() electrumxv3.connect() const encryptionv3 = new EncryptionV3() +const pricev3 = new PriceV3() const app = express() @@ -121,6 +123,7 @@ app.use(`/${v3prefix}/` + 'xpub', xpubV3.router) app.use(`/${v3prefix}/` + 'blockbook', blockbookV3.router) app.use(`/${v3prefix}/` + 'electrumx', electrumxv3.router) app.use(`/${v3prefix}/` + 'encryption', encryptionv3.router) +app.use(`/${v3prefix}/` + 'price', pricev3.router) const ninsight = new Ninsight() app.use(`/${v3prefix}/` + 'ninsight', ninsight.router) diff --git a/src/routes/v3/price.js b/src/routes/v3/price.js new file mode 100644 index 0000000..32e5ec5 --- /dev/null +++ b/src/routes/v3/price.js @@ -0,0 +1,118 @@ +/* + This price route is really just a wrapper for another API price endpoint. + The main reason for hosting this price wrapper is so that the price can + be accessible over Tor. Tor is typically blocked by other REST API servers. +*/ + +'use strict' + +const express = require('express') +const axios = require('axios') +const wlogger = require('../../util/winston-logging') +const util = require('util') + +const RouteUtils = require('../../util/route-utils') +const routeUtils = new RouteUtils() + +let _this // Global context for 'this' instance of the Class. + +class Price { + constructor () { + _this = this + + this.axios = axios + this.routeUtils = routeUtils + + this.priceUrl = 'https://api.coinbase.com/v2/exchange-rates?currency=BCH' + + this.router = express.Router() + this.router.get('/', _this.root) + this.router.get('/usd', _this.getUSD) + this.router.get('/rates', _this.getBCHRate) + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + // Root API endpoint. Simply acknowledges that it exists. + root (req, res, next) { + return res.json({ status: 'price' }) + } + + /** + * @api {get} /price/usd Get the USD price of BCH + * @apiName Get the USD price of BCH + * @apiGroup Price + * @apiDescription Get the USD price of BCH + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v3/price/usd" -H "accept: application/json" + * + */ + async getUSD (req, res, next) { + try { + // Request options + const opt = { + method: 'get', + baseURL: _this.priceUrl, + timeout: 15000 + } + + const response = await axios.request(opt) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + return res.json({ usd: Number(response.data.data.rates.USD) }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in price.js/getUSD().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /price/usd Get rates for several different currencies + * @apiName Get rates for several different currencies + * @apiGroup Price + * @apiDescription Get rates for several different currencies + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v3/price/rates" -H "accept: application/json" + * + */ + // Get rates for several different currencies + async getBCHRate (req, res, next) { + try { + // Request options + const opt = { + method: 'get', + baseURL: _this.priceUrl, + timeout: 15000 + } + + const response = await axios.request(opt) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + return res.json(response.data.data.rates) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in price.js/getUSD().', err) + + return _this.errorHandler(err, res) + } + } +} + +module.exports = Price diff --git a/src/routes/v3/slp.js b/src/routes/v3/slp.js index 551a694..b818062 100644 --- a/src/routes/v3/slp.js +++ b/src/routes/v3/slp.js @@ -1598,7 +1598,7 @@ class Slp { * not been confirmed. * * @apiExample Example usage: - * curl -X POST "https://api.fullstack.cash/v3/slp/hydrateUtxos" -H "accept:application/json" -H "Content-Type: application/json" -d '{"utxos":[{"txid": "d56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56","vout": 3, "value": "6816", "height": 606848, "confirmations": 13, "satoshis": 6816}, {"txid": "d56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56","vout": 2, "value": "546", "height": 606848, "confirmations": 13, "satoshis": 546}]}' + * curl -X POST "https://api.fullstack.cash/v3/slp/hydrateUtxos" -H "accept:application/json" -H "Content-Type: application/json" -d '{"utxos":[{"utxos":[{"txid": "d56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56","vout": 3, "value": "6816", "height": 606848, "confirmations": 13, "satoshis": 6816}, {"txid": "d56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56","vout": 2, "value": "546", "height": 606848, "confirmations": 13, "satoshis": 546}]}]}' * * */ diff --git a/test/v3/integration/price.js b/test/v3/integration/price.js new file mode 100644 index 0000000..33106ad --- /dev/null +++ b/test/v3/integration/price.js @@ -0,0 +1,34 @@ +/* + Integration tests for the price library. + */ + +'use strict' + +// const assert = require('chai').assert +// const axios = require('axios') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +const Price = require('../../../src/routes/v3/price') +const price = new Price() + +const { mockReq, mockRes } = require('../mocks/express-mocks') + +describe('#price', () => { + let req, res + + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + }) + + describe('#getUSD', () => { + it('should get the USD price', async () => { + const result = await price.getUSD(req, res) + console.log(`result: ${util.inspect(result)}`) + }) + }) +}) diff --git a/test/v3/mocks/price-mock.js b/test/v3/mocks/price-mock.js new file mode 100644 index 0000000..6e3168b --- /dev/null +++ b/test/v3/mocks/price-mock.js @@ -0,0 +1,221 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +const mockCoinbaseFeed = { + data: { + currency: 'BCH', + rates: { + AED: '918.11634', + AFN: '19208.6584998', + ALGO: '823.28722002635056355', + ALL: '26365.4673517', + AMD: '120379.9761886', + ANG: '448.6632494', + AOA: '162813.68075', + ARS: '19392.84240565', + ATOM: '45.3670932026499700525', + AUD: '354.4385981', + AWG: '449.91', + AZN: '425.539875', + BAL: '18.824215193276753072', + BAM: '415.45514235', + BAND: '40.7768732564399590085', + BAT: '1168.9711697428450012', + BBD: '499.9', + BCH: '1.0', + BDT: '21194.1523216', + BGN: '415.36691', + BHD: '94.2501462', + BIF: '483670.78174295', + BMD: '249.95', + BND: '339.227141', + BOB: '1725.8732563', + BRL: '1401.494645', + BSD: '249.95', + BSV: '1.567361207606150942625', + BTC: '0.021275', + BTN: '18323.91073475', + BWP: '2859.81217315', + BYN: '640.5913561', + BYR: '6405913.561', + BZD: '503.8107177', + CAD: '329.6765515', + CDF: '490377.5463717', + CGLD: '121.6805004503079241715', + CHF: '227.5939721', + CLF: '7.13432285', + CLP: '196860.63474705', + CNH: '1669.50978125', + CNY: '1670.21589', + COMP: '2.429765723728978241', + COP: '961722.29431455', + CRC: '150790.4338804', + CUC: '250.01273745', + CVE: '23620.275', + CZK: '5790.09175', + DAI: '247.842348666936355635', + DASH: '3.338230383973288798', + DJF: '44495.8985401', + DKK: '1580.608815', + DOP: '14597.4179324', + DZD: '32214.830745', + EEK: '3652.69206545', + EGP: '3924.839875', + EOS: '96.8423091824874092665', + ERN: '3749.25274945', + ETB: '9313.6508972', + ETC: '45.04821122825989111', + ETH: '0.658777328255340453815', + EUR: '212.33', + FJD: '534.9054975', + FKP: '193.01413945', + GBP: '193.115', + GEL: '807.3385', + GGP: '193.01413945', + GHS: '1453.70845015', + GIP: '193.01413945', + GMD: '12934.9125', + GNF: '2447970.79115335', + GTQ: '1944.576007', + GYD: '52260.2868518', + HKD: '1937.122498', + HNL: '6129.328889', + HRK: '1610.802775', + HTG: '15746.55080985', + HUF: '77512.6058777', + IDR: '3675351.03275', + ILS: '846.1232415', + IMP: '193.01413945', + INR: '18345.655135', + IQD: '298009.02802165', + ISK: '34745.5495', + JEP: '193.01413945', + JMD: '36409.6696094', + JOD: '177.21455', + JPY: '26360.601825', + KES: '27184.562', + KGS: '20308.0755724', + KHR: '1025880.7533059', + KMF: '105029.03124175', + KNC: '274.68542227594924615', + KRW: '284915.23080495', + KWD: '76.4532063', + KYD: '208.2943328', + KZT: '107014.42658325', + LAK: '2310183.71158185', + LBP: '378370.54450325', + LINK: '22.894634759817007489', + LKR: '46090.95971405', + LRC: '1457.8594342373869446', + LRD: '48852.72175115', + LSL: '4122.3008749', + LTC: '5.2062070401999589123', + LTL: '806.05000775', + LVL: '164.03243695', + LYD: '341.5926678', + MAD: '2298.47346335', + MDL: '4228.5806147', + MGA: '982179.7557538', + MKD: '13087.73767885', + MKR: '0.43764476902003812305', + MMK: '322679.7621378', + MNT: '709349.8756452', + MOP: '1995.2583685', + MRO: '89232.15', + MTL: '170.9003131', + MUR: '9974.21650765', + MVR: '3849.23', + MWK: '188665.67006785', + MXN: '5298.265135', + MYR: '1036.517655', + MZN: '18229.3548997', + NAD: '4134.173', + NGN: '95481.03472305', + NIO: '8698.6554209', + NMR: '8.624556609112112958', + NOK: '2334.203066', + NPR: '29318.1806909', + NZD: '379.2351378', + OMG: '74.464123456422320125', + OMR: '96.23549905', + OXT: '1033.27821413807369045', + PAB: '249.95', + PEN: '896.6071427', + PGK: '874.90073485', + PHP: '12136.85639315', + PKR: '40613.9575836', + PLN: '971.640633', + PYG: '1756126.21474795', + QAR: '910.1304375', + REN: '769.6689761354887733', + REP: '18.2912550311013566725', + REPV2: '18.1582473904586151205', + RON: '1035.54285', + RSD: '24943.76025', + RUB: '19402.093805', + RWF: '244237.3512583', + SAR: '937.65668115', + SBD: '2019.805958', + SCR: '4576.6749819', + SEK: '2207.698372', + SGD: '339.57582125', + SHP: '193.01413945', + SLL: '2490751.74625075', + SOS: '145044.39506805', + SRD: '3537.7923', + SSP: '32558.487', + STD: '5250063.0293496', + SVC: '2187.04525345', + SZL: '4118.75383445', + THB: '7800.314625', + TJS: '2579.48624955', + TMT: '874.825', + TND: '688.2998125', + TOP: '579.953986', + TRY: '1969.70598', + TTD: '1696.3476626', + TWD: '7179.16412995', + TZS: '579879.84308155', + UAH: '7090.3476468', + UGX: '935325.80066935', + UMA: '29.276720351390922145', + UNI: '78.76161966283282792', + USD: '249.95', + USDC: '249.95', + UYU: '10724.30446005', + UZS: '2592145.88136715', + VEF: '62109486.17813795', + VES: '113073216.80555545', + VND: '5792476.0820382', + VUV: '28582.2184128', + WST: '655.5403657', + XAF: '139318.05656485', + XAG: '10.233577875', + XAU: '0.1313462255', + XCD: '675.5023725', + XDR: '177.0940741', + XLM: '2935.3219224332811736', + XOF: '139318.05656485', + XPD: '0.1065011955', + XPF: '25344.75028595', + XPT: '0.290981792', + XRP: '1016.6768354687817832', + XTZ: '114.010080507218287957', + YER: '62574.97275195', + YFI: '0.0181099566723181033515', + ZAR: '4130.57372', + ZEC: '3.88966697790227161905', + ZMK: '1313006.15998725', + ZMW: '5048.6210738', + ZRX: '642.804422350408735165', + ZWL: '80483.9' + } + } +} + +module.exports = { + mockCoinbaseFeed +} diff --git a/test/v3/price.js b/test/v3/price.js new file mode 100644 index 0000000..23dab3e --- /dev/null +++ b/test/v3/price.js @@ -0,0 +1,214 @@ +/* + TESTS FOR THE PRICE.JS LIBRARY + + This test file uses the environment variable TEST to switch between unit + and integration tests. By default, TEST is set to 'unit'. Set this variable + to 'integration' to run the tests against BCH mainnet. + +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const sinon = require('sinon') + +const Price = require('../../src/routes/v3/price') +let uut + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/price-mock') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +describe('#PriceRouter', () => { + let req, res + let sandbox + + before(() => { + // Set default environment variables for unit tests. + if (!process.env.TEST) process.env.TEST = 'unit' + }) + + // Setup the mocks before each test. + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + + // Explicitly reset the parmas and body. + req.params = {} + req.body = {} + req.query = {} + + sandbox = sinon.createSandbox() + + uut = new Price() + }) + + afterEach(() => { + // Restore Sandbox + sandbox.restore() + }) + + after(() => {}) + + describe('#root', async () => { + // root route handler. + // const root = controlRoute.testableComponents.root + + it('should respond to GET for base route', async () => { + const result = uut.root(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(result.status, 'price', 'Returns static string') + }) + }) + + describe('#getUSD', () => { + // const getNetworkInfo = controlRoute.testableComponents.getNetworkInfo + + it('should throw 500 when network issues', async () => { + uut.priceUrl = 'http://fakeurl/api/' + + await uut.getUSD(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + // console.log(res) + assert.include( + res.output.error, + 'Network error: Could not communicate with full node or other external service' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getUSD(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getUSD(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get the USD price of BCH', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: mockData.mockCoinbaseFeed }) + } + + const result = await uut.getUSD(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result.usd) + }) + }) + + describe('#getBCHRate', () => { + it('should throw 500 when network issues', async () => { + uut.priceUrl = 'http://fakeurl/api/' + + await uut.getBCHRate(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + // console.log(res) + assert.include( + res.output.error, + 'Network error: Could not communicate with full node or other external service' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getBCHRate(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getBCHRate(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get several rates for BCH', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: mockData.mockCoinbaseFeed }) + } + + const result = await uut.getBCHRate(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // assert.isNumber(result) + assert.property(result, 'USD') + assert.property(result, 'CAD') + }) + }) + + describe('#errorHandler', () => { + it('should handle unexpected errors', () => { + sandbox.stub(uut.routeUtils, 'decodeError').returns({ msg: false }) + + const result = uut.errorHandler(new Error('test error'), res) + // console.log('result: ', result) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.property(result, 'error') + }) + }) +})