-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add initial provider API tests for Infura client
We are working on migrating the extension to a unified network controller, but before we do so we want to extract some of the existing pieces, specifically `createInfuraClient` and `createJsonRpcClient`, which provide the majority of the behavior exhibited within the provider API that the existing NetworkController exposes. This necessitates that we understand and test that behavior as a whole. With that in mind, this commit starts with the Infura-specific network client and adds some initial functional tests for `createInfuraClient`, specifically covering three pieces of middleware provided by `eth-json-rpc-middleware`: `createNetworkAndChainIdMiddleware`, `createBlockCacheMiddleware`, and `createBlockRefMiddleware`. These tests exercise logic that originate from multiple different places and combine in sometimes surprising ways, and as a result, understanding the nature of the tests can be tricky. I've tried to explain the logic (both of the implementation and the tests) via comments. Additionally, debugging why a certain test is failing is not the most fun thing in the world, so to aid with this, I've added some logging to the underlying packages used when a request passes through the middleware stack. Because some middleware change the request being made, or make new requests altogether, this greatly helps to peel back the curtain, as failures from Nock do not supply much meaningful information on their own. This logging is disabled by default, but can be activated by setting `DEBUG_PROVIDER_TESTS=1` alongside the `jest` command. Also note that we are using a custom version of `eth-block-tracker` which provides a `destroy` method, which we use in tests to properly ensure that the block tracker is stopped before moving on to the next step. This change comes from [this PR][1] which has yet to be merged. [1]: MetaMask/eth-block-tracker#106
- Loading branch information
Showing
8 changed files
with
1,284 additions
and
12 deletions.
There are no files selected for viewing
834 changes: 834 additions & 0 deletions
834
app/scripts/controllers/network/createInfuraClient.test.js
Large diffs are not rendered by default.
Oops, something went wrong.
272 changes: 272 additions & 0 deletions
272
app/scripts/controllers/network/provider-test-helpers.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,272 @@ | ||
import nock from 'nock'; | ||
import sinon from 'sinon'; | ||
import { JsonRpcEngine } from 'json-rpc-engine'; | ||
import { providerFromEngine } from 'eth-json-rpc-middleware'; | ||
import EthQuery from 'eth-query'; | ||
import createInfuraClient from './createInfuraClient'; | ||
|
||
/** | ||
* @typedef {import('nock').Scope} NockScope | ||
* | ||
* A object returned by `nock(...)` for mocking requests to a particular base | ||
* URL. | ||
*/ | ||
|
||
/** | ||
* @typedef {{makeRpcCall: (request: Partial<JsonRpcRequest>) => Promise<any>, makeRpcCallsInSeries: (requests: Partial<JsonRpcRequest>[]) => Promise<any>}} InfuraClient | ||
* | ||
* Provides methods to interact with the suite of middleware that | ||
* `createInfuraClient` exposes. | ||
*/ | ||
|
||
/** | ||
* @typedef {{network: string}} WithInfuraClientOptions | ||
* | ||
* The options bag that `withInfuraClient` takes. | ||
*/ | ||
|
||
/** | ||
* @typedef {(client: InfuraClient) => Promise<any>} WithInfuraClientCallback | ||
* | ||
* The callback that `withInfuraClient` takes. | ||
*/ | ||
|
||
/** | ||
* @typedef {[WithInfuraClientOptions, WithInfuraClientCallback] | [WithInfuraClientCallback]} WithInfuraClientArgs | ||
* | ||
* The arguments to `withInfuraClient`. | ||
*/ | ||
|
||
/** | ||
* @typedef {{ nockScope: NockScope, blockNumber: string }} MockNextBlockTrackerRequestOptions | ||
* | ||
* The options to `mockNextBlockTrackerRequest`. | ||
*/ | ||
|
||
/** | ||
* @typedef {{ nockScope: NockScope, request: object, response: object, delay?: number }} MockSuccessfulInfuraRpcCallOptions | ||
* | ||
* The options to `mockSuccessfulInfuraRpcCall`. | ||
*/ | ||
|
||
/** | ||
* @typedef {{mockNextBlockTrackerRequest: (options: Omit<MockNextBlockTrackerRequestOptions, 'nockScope'>) => void, mockSuccessfulInfuraRpcCall: (options: Omit<MockSuccessfulInfuraRpcCallOptions, 'nockScope'>) => NockScope}} InfuraCommunications | ||
* | ||
* Provides methods to mock different kinds of requests to Infura. | ||
*/ | ||
|
||
/** | ||
* @typedef {{network: string}} InfuraCommunicationsOptions | ||
* | ||
* The options bag that `mockingInfuraCommunications` takes. | ||
*/ | ||
|
||
/** | ||
* @typedef {(client: InfuraClient) => Promise<any>} InfuraCommunicationsOptionsCallback | ||
* | ||
* The callback that `mockingInfuraCommunications` takes. | ||
*/ | ||
|
||
/** | ||
* @typedef {[MockingInfuraCommunicationsOptions, MockingInfuraCommunicationsCallback] | [WithInfuraClientCallback]} MockingInfuraCommunicationsArgs | ||
* | ||
* The arguments to `mockingInfuraCommunications`. | ||
*/ | ||
|
||
const INFURA_PROJECT_ID = 'abc123'; | ||
const DEFAULT_LATEST_BLOCK_NUMBER = '0x42'; | ||
|
||
/** | ||
* If you're having trouble writing a test and you're wondering why the test | ||
* keeps failing, you can set `process.env.DEBUG_PROVIDER_TESTS` to `1`. This | ||
* will turn on some extra logging. | ||
* | ||
* @param {any[]} args - The arguments that `console.log` takes. | ||
*/ | ||
function debug(...args) { | ||
if (process.env.DEBUG_PROVIDER_TESTS === '1') { | ||
console.log(...args); | ||
} | ||
} | ||
|
||
/** | ||
* Builds a Nock scope object for mocking requests to a particular network that | ||
* Infura supports. | ||
* | ||
* @param {object} options - The options. | ||
* @param {string} options.network - The Infura network you're testing with | ||
* (default: "mainnet"). | ||
* @returns {NockScope} The nock scope. | ||
*/ | ||
function buildScopeForMockingInfuraRequests({ network = 'mainnet' } = {}) { | ||
return nock(`https://${network}.infura.io`).filteringRequestBody((body) => { | ||
const copyOfBody = JSON.parse(body); | ||
// some ids are random, so remove them entirely from the request to | ||
// make it possible to mock these requests | ||
delete copyOfBody.id; | ||
return JSON.stringify(copyOfBody); | ||
}); | ||
} | ||
|
||
/** | ||
* Mocks the next request for the latest block that the block tracker will make. | ||
* | ||
* @param {MockNextBlockTrackerRequestOptions} args - The arguments. | ||
* @param {NockScope} args.nockScope - A nock scope (a set of mocked requests | ||
* scoped to a certain base URL). | ||
* @param {string} args.blockNumber - The block number that the block tracker | ||
* should report, as a 0x-prefixed hex string. | ||
*/ | ||
async function mockNextBlockTrackerRequest({ | ||
nockScope, | ||
blockNumber = DEFAULT_LATEST_BLOCK_NUMBER, | ||
}) { | ||
await mockSuccessfulInfuraRpcCall({ | ||
nockScope, | ||
request: { method: 'eth_blockNumber', params: [] }, | ||
response: { result: blockNumber }, | ||
}); | ||
} | ||
|
||
/** | ||
* Mocks a JSON-RPC request sent to Infura with the given response. | ||
* | ||
* @param {MockSuccessfulInfuraRpcCallOptions} args - The arguments. | ||
* @param {NockScope} args.nockScope - A nock scope (a set of mocked requests | ||
* scoped to a certain base URL). | ||
* @param {object} args.request - The request data. | ||
* @param {object} args.response - The response that the request should have. | ||
* @param {number} args.delay - The amount of time that should pass before the | ||
* request resolves with the response. | ||
* @returns {NockScope} The nock scope. | ||
*/ | ||
function mockSuccessfulInfuraRpcCall({ nockScope, request, response, delay }) { | ||
// eth-query always passes `params`, so even if we don't supply this property | ||
// for consistency with makeRpcCall, assume that the `body` contains it | ||
const { method, params = [], ...rest } = request; | ||
const completeResponse = { | ||
id: 1, | ||
jsonrpc: '2.0', | ||
...response, | ||
}; | ||
const nockRequest = nockScope.post(`/v3/${INFURA_PROJECT_ID}`, { | ||
jsonrpc: '2.0', | ||
method, | ||
params, | ||
...rest, | ||
}); | ||
|
||
if (delay !== undefined) { | ||
nockRequest.delay(delay); | ||
} | ||
|
||
return nockRequest.reply(200, completeResponse); | ||
} | ||
|
||
/** | ||
* Makes a JSON-RPC call through the given eth-query object. | ||
* | ||
* @param {any} ethQuery - The eth-query object. | ||
* @param {object} request - The request data. | ||
* @returns {Promise<any>} A promise that either resolves with the result from | ||
* the JSON-RPC response if it is successful or rejects with the error from the | ||
* JSON-RPC response otherwise. | ||
*/ | ||
function makeRpcCall(ethQuery, request) { | ||
return new Promise((resolve, reject) => { | ||
debug('[makeRpcCall] making request', request); | ||
ethQuery.sendAsync(request, (error, result) => { | ||
debug('[makeRpcCall > ethQuery handler] error', error, 'result', result); | ||
if (error) { | ||
reject(error); | ||
} else { | ||
resolve(result); | ||
} | ||
}); | ||
}); | ||
} | ||
|
||
/** | ||
* Sets up request mocks for requests to Infura. | ||
* | ||
* @param {MockingInfuraCommunicationsArgs} args - Either an options bag + a | ||
* function, or just a function. The options bag, at the moment, may contain | ||
* `network` (that is, the Infura network; defaults to "mainnet"). The function | ||
* is called with an object that allows you to mock different kinds of requests. | ||
* @returns {Promise<any>} The return value of the given function. | ||
*/ | ||
export async function mockingInfuraCommunications(...args) { | ||
const [options, fn] = args.length === 2 ? args : [{}, args[0]]; | ||
const { network = 'mainnet' } = options; | ||
|
||
const nockScope = buildScopeForMockingInfuraRequests({ network }); | ||
const curriedMockNextBlockTrackerRequest = (localOptions) => | ||
mockNextBlockTrackerRequest({ nockScope, ...localOptions }); | ||
const curriedMockSuccessfulInfuraRpcCall = (localOptions) => | ||
mockSuccessfulInfuraRpcCall({ nockScope, ...localOptions }); | ||
|
||
try { | ||
return await fn({ | ||
mockNextBlockTrackerRequest: curriedMockNextBlockTrackerRequest, | ||
mockSuccessfulInfuraRpcCall: curriedMockSuccessfulInfuraRpcCall, | ||
}); | ||
} finally { | ||
nock.isDone(); | ||
nock.cleanAll(); | ||
} | ||
} | ||
|
||
/** | ||
* Builds a provider from the Infura middleware along with a block tracker, runs | ||
* the given function with those two things, and then ensures the block tracker | ||
* is stopped at the end. | ||
* | ||
* @param {WithInfuraClientArgs} args - Either an options bag + a function, or | ||
* just a function. The options bag, at the moment, may contain `network` (that | ||
* is, the Infura network; defaults to "mainnet"). The function is called with | ||
* an object that allows you to interact with the client via a couple of methods | ||
* on that object. | ||
* @returns {Promise<any>} The return value of the given function. | ||
*/ | ||
export async function withInfuraClient(...args) { | ||
const [options, fn] = args.length === 2 ? args : [{}, args[0]]; | ||
const { network = 'mainnet' } = options; | ||
|
||
const { networkMiddleware, blockTracker } = createInfuraClient({ | ||
network, | ||
projectId: INFURA_PROJECT_ID, | ||
}); | ||
|
||
const engine = new JsonRpcEngine(); | ||
engine.push(networkMiddleware); | ||
const provider = providerFromEngine(engine); | ||
|
||
const ethQuery = new EthQuery(provider); | ||
const curriedMakeRpcCall = (request) => makeRpcCall(ethQuery, request); | ||
const makeRpcCallsInSeries = async (requests) => { | ||
const responses = []; | ||
for (const request of requests) { | ||
responses.push(await curriedMakeRpcCall(request)); | ||
} | ||
return responses; | ||
}; | ||
|
||
// Faking timers ends up doing two things: | ||
// 1. Halting the block tracker (which depends on `setTimeout` to periodically | ||
// request the latest block) set up in `eth-json-rpc-middleware` | ||
// 2. Halting the retry logic in `eth-json-rpc-infura` (which also depends on | ||
// `setTimeout`) | ||
const clock = sinon.useFakeTimers(); | ||
|
||
try { | ||
return await fn({ | ||
makeRpcCall: curriedMakeRpcCall, | ||
makeRpcCallsInSeries, | ||
clock, | ||
}); | ||
} finally { | ||
await blockTracker.destroy(); | ||
|
||
clock.restore(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
diff --git a/node_modules/eth-block-tracker/dist/PollingBlockTracker.js b/node_modules/eth-block-tracker/dist/PollingBlockTracker.js | ||
index b0c1ab8..b4d88b0 100644 | ||
--- a/node_modules/eth-block-tracker/dist/PollingBlockTracker.js | ||
+++ b/node_modules/eth-block-tracker/dist/PollingBlockTracker.js | ||
@@ -9,6 +9,11 @@ const pify_1 = __importDefault(require("pify")); | ||
const BaseBlockTracker_1 = require("./BaseBlockTracker"); | ||
const createRandomId = (0, json_rpc_random_id_1.default)(); | ||
const sec = 1000; | ||
+const debug = (...args) => { | ||
+ if (process.env.DEBUG_PROVIDER_TESTS === '1') { | ||
+ console.log(...args) | ||
+ } | ||
+} | ||
class PollingBlockTracker extends BaseBlockTracker_1.BaseBlockTracker { | ||
constructor(opts = {}) { | ||
var _a; | ||
@@ -76,7 +81,9 @@ class PollingBlockTracker extends BaseBlockTracker_1.BaseBlockTracker { | ||
if (this._setSkipCacheFlag) { | ||
req.skipCache = true; | ||
} | ||
+ debug('[eth-block-tracker > PollingBlockTracker] making request', req); | ||
const res = await (0, pify_1.default)((cb) => this._provider.sendAsync(req, cb))(); | ||
+ debug('[eth-block-tracker > PollingBlockTracker] got response', res); | ||
if (res.error) { | ||
throw new Error(`PollingBlockTracker - encountered error fetching block:\n${res.error.message}`); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
diff --git a/node_modules/eth-json-rpc-infura/src/index.js b/node_modules/eth-json-rpc-infura/src/index.js | ||
index 72fbdd4..7cfa3d4 100644 | ||
--- a/node_modules/eth-json-rpc-infura/src/index.js | ||
+++ b/node_modules/eth-json-rpc-infura/src/index.js | ||
@@ -1,6 +1,11 @@ | ||
const createAsyncMiddleware = require('json-rpc-engine/src/createAsyncMiddleware') | ||
const { ethErrors } = require('eth-rpc-errors') | ||
const fetch = require('node-fetch') | ||
+const debug = (...args) => { | ||
+ if (process.env.DEBUG_PROVIDER_TESTS === '1') { | ||
+ console.log(...args) | ||
+ } | ||
+} | ||
|
||
const RETRIABLE_ERRORS = [ | ||
// ignore server overload errors | ||
@@ -43,6 +48,7 @@ function createInfuraMiddleware (opts = {}) { | ||
// an error was caught while performing the request | ||
// if not retriable, resolve with the encountered error | ||
if (!isRetriableError(err)) { | ||
+ debug('[eth-json-rpc-infura] Non-retriable request error encountered. req = ', req, ', res = ', res, 'error = ', err) | ||
// abort with error | ||
throw err | ||
} |
Oops, something went wrong.