UNPKG

mock-chronik-client

Version:

Testing utility to mock the Chronik indexer client and support unit tests that need to mock chronik related API calls.

556 lines 23.5 kB
"use strict"; // Copyright (c) 2024 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. Object.defineProperty(exports, "__esModule", { value: true }); exports.MockWsEndpoint = exports.MockChronikClient = exports.MockAgora = exports.PLUGIN_NAME = exports.GROUP_TOKEN_ID_PREFIX = exports.PUBKEY_PREFIX = exports.TOKEN_ID_PREFIX = void 0; const ecashaddrjs_1 = require("ecashaddrjs"); const CHRONIK_DEFAULT_PAGESIZE = 25; exports.TOKEN_ID_PREFIX = '54'; // toHex(strToBytes('T')); exports.PUBKEY_PREFIX = '50'; // toHex(strToBytes('P')); exports.GROUP_TOKEN_ID_PREFIX = '47'; // toHex(strToBytes('G')); exports.PLUGIN_NAME = 'agora'; /** * MockAgora * Useful test mock for writing unit tests for functions that use the Agora * class from the ecash-agora library. Drop-in replacement for Agora object * to unit test functions that accept an intialized Agora object as a param. * In this way, can write unit tests without hitting an actual chronik API. * * Mock calls to chronik nodes indexed with the ecash-agora plugin * * See Cashtab and ecash-herald for implementation examples. * * Note: not all Agora methods are mocked, can be extended as needed */ class MockAgora { // Agora can make specialized chronik-client calls to a chronik-client instance // running the agora plugin // For the purposes of unit testing, we only need to re-create how this object // is initialized and support getting and setting of expected responses constructor() { // Allow user to set supported agora query responses this.setOfferedGroupTokenIds = (expectedResponse) => { this.mockedResponses.offeredGroupTokenIds = expectedResponse; }; this.setOfferedFungibleTokenIds = (expectedResponse) => { this.mockedResponses.offeredFungibleTokenIds = expectedResponse; }; this.setActiveOffersByPubKey = (pubKey, expectedResponse) => { this.mockedResponses.activeOffersByPubKey[pubKey] = expectedResponse; }; this.setActiveOffersByGroupTokenId = (groupTokenId, expectedResponse) => { this.mockedResponses.activeOffersByGroupTokenId[groupTokenId] = expectedResponse; }; this.setActiveOffersByTokenId = (tokenId, expectedResponse) => { this.mockedResponses.activeOffersByTokenId[tokenId] = expectedResponse; }; this.offeredGroupTokenIds = async () => { return this._throwOrReturnValue(this.mockedResponses.offeredGroupTokenIds); }; this.offeredFungibleTokenIds = async () => { return this._throwOrReturnValue(this.mockedResponses.offeredFungibleTokenIds); }; this.activeOffersByPubKey = async (pubKey) => { return this._throwOrReturnValue(this.mockedResponses.activeOffersByPubKey[pubKey]); }; this.activeOffersByGroupTokenId = async (groupTokenId) => { return this._throwOrReturnValue(this.mockedResponses.activeOffersByGroupTokenId[groupTokenId]); }; this.activeOffersByTokenId = async (tokenId) => { return this._throwOrReturnValue(this.mockedResponses.activeOffersByTokenId[tokenId]); }; /** Subscribe to updates from the websocket for some params */ this.subscribeWs = (ws, params) => { const groupHex = this._groupHex(params); ws.subscribeToPlugin(exports.PLUGIN_NAME, groupHex); }; /** Unsubscribe from updates from the websocket for some params */ this.unsubscribeWs = (ws, params) => { const groupHex = this._groupHex(params); ws.unsubscribeFromPlugin(exports.PLUGIN_NAME, groupHex); }; this._groupHex = (params) => { switch (params.type) { case 'TOKEN_ID': return exports.TOKEN_ID_PREFIX + params.tokenId; case 'GROUP_TOKEN_ID': return exports.GROUP_TOKEN_ID_PREFIX + params.groupTokenId; case 'PUBKEY': return exports.PUBKEY_PREFIX + params.pubkeyHex; default: throw new Error('Unsupported type'); } }; // API call mock return objects // Can be set with self.setMock this.mockedResponses = { offeredGroupTokenIds: [], offeredFungibleTokenIds: [], activeOffersByPubKey: {}, activeOffersByGroupTokenId: {}, activeOffersByTokenId: {}, }; } // Checks whether the user set this mock response to be an error. // If so, throw it to simulate an API error response. _throwOrReturnValue(mockResponse) { if (mockResponse instanceof Error) { throw mockResponse; } return mockResponse; } } exports.MockAgora = MockAgora; /** * MockChronikClient * Drop-in replacement for ChronikClient in unit tests * * See Cashtab, token-server, ecash-herald for implementation * * Can set expected return values to test ChronikClient functions * without hitting an API */ class MockChronikClient { constructor() { // Public methods from ChronikClient that are mocked by MockChronikClient // Note that there are often peculiar / specific ways to set these mocks // See tests for examples this.block = async (blockHashOrHeight) => { return this._throwOrReturnValue(this.mockedResponses.block[blockHashOrHeight]); }; this.tx = async (txid) => { return this._throwOrReturnValue(this.mockedResponses.tx[txid]); }; this.token = async (tokenId) => { return this._throwOrReturnValue(this.mockedResponses.token[tokenId]); }; this.blockTxs = async (hashOrHeight, pageNumber = 0, pageSize = CHRONIK_DEFAULT_PAGESIZE) => { if (this.mockedResponses.blockTxs[hashOrHeight].history instanceof Error) { throw this.mockedResponses.blockTxs[hashOrHeight].history; } return this._getTxHistory(pageNumber, pageSize, this.mockedResponses.blockTxs[hashOrHeight].history); }; this.broadcastTx = async (txHex) => { return this._throwOrReturnValue(this.mockedResponses.broadcastTx[txHex]); }; this.broadcastTxs = async (txsHex) => { const returns = []; for (const txHex of txsHex) { if (this.mockedResponses.broadcastTx[txHex] instanceof Error) { throw this.mockedResponses.broadcastTx[txHex]; } else { returns.push(this.mockedResponses.broadcastTx[txHex]); } } return returns; }; this.blockchainInfo = async () => { return this._throwOrReturnValue(this.mockedResponses.blockchainInfo); }; // Return assigned script mocks this.script = (type, hash) => { return this.mockedMethods.script[type][hash]; }; // Return assigned address mocked methods this.address = (address) => { return this.mockedMethods.address[address]; }; // Return assigned tokenId mocked methods this.tokenId = (tokenId) => { return this.mockedMethods.tokenId[tokenId]; }; // Return assigned lokadId mocked methods this.lokadId = (lokadId) => { return this.mockedMethods.lokadId[lokadId]; }; // Websocket mocks this.ws = (wsConfig) => { const returnedWs = new MockWsEndpoint(wsConfig); return returnedWs; }; this.setBlock = (hashOrHeight, block) => { this.mockedResponses.block[hashOrHeight] = block; }; this.setTx = (txid, tx) => { this.mockedResponses.tx[txid] = tx; }; this.setToken = (tokenId, token) => { this.mockedResponses.token[tokenId] = token; }; this.setBlockchainInfo = (blockchainInfo) => { this.mockedResponses.blockchainInfo = blockchainInfo; }; this.setChronikInfo = (versionInfo) => { this.mockedResponses.chronikInfo = versionInfo; }; this.chronikInfo = async () => { return this._throwOrReturnValue(this.mockedResponses.chronikInfo); }; this.setBroadcastTx = (rawTx, txidOrError) => { this.mockedResponses.broadcastTx[rawTx] = typeof txidOrError === 'string' ? { txid: txidOrError } : txidOrError; }; this.setTxHistoryByScript = (type, hash, history) => { this._setScript(type, hash); this.mockedResponses.script[type][hash].history = history; }; this.setTxHistoryByAddress = (address, history) => { this._setAddress(address); this.mockedResponses.address[address].history = history; }; this.setTxHistoryByTokenId = (tokenId, history) => { this._setTokenId(tokenId); this.mockedResponses.tokenId[tokenId].history = history; }; this.setTxHistoryByLokadId = (lokadId, history) => { this._setLokadId(lokadId); this.mockedResponses.lokadId[lokadId].history = history; }; this.setTxHistoryByBlock = (hashOrHeight, history) => { // Set all expected tx history as array where it can be accessed by mock method this.mockedResponses.blockTxs[hashOrHeight] = { history }; }; // Set utxos to custom response; must be called after setScript this.setUtxosByScript = (type, hash, utxos) => { this._setScript(type, hash); this.mockedResponses.script[type][hash].utxos = utxos; }; // Set utxos to custom response; must be called after setAddress this.setUtxosByAddress = (address, utxos) => { this._setAddress(address); this.mockedResponses.address[address].utxos = utxos; }; // Set utxos to custom response; must be called after setTokenId this.setUtxosByTokenId = (tokenId, utxos) => { this._setTokenId(tokenId); this.mockedResponses.tokenId[tokenId].utxos = utxos; }; // We need to give mockedChronik a plugin function // This is required for creating a new Agora(mockedChronik) this.plugin = () => 'dummy plugin'; // Dummy values not supported by MockChronikClient this._proxyInterface = {}; this.proxyInterface = {}; // Methods not supported by MockChronikClient this.blocks = () => { console.info('MockChronikClient does not support blocks'); }; this.rawTx = () => { console.info('MockChronikClient does not support rawTx'); }; // API call mock return objects // Can be set with self.setMock this.mockedResponses = { block: {}, blockTxs: {}, tx: {}, token: {}, blockchainInfo: {}, chronikInfo: { version: 'unset' }, address: {}, tokenId: {}, lokadId: {}, script: { p2sh: {}, p2pkh: {} }, broadcastTx: {}, }; this.mockedMethods = { script: { p2pkh: {}, p2sh: {} }, address: {}, tokenId: {}, lokadId: {}, }; } // Allow users to set expected chronik address call responses _setAddress(address) { // Do not overwrite existing history, but initialize if nothing is there if (typeof this.mockedResponses.address[address] === 'undefined') { this.mockedResponses.address[address] = { history: [], utxos: [], }; } this.mockedMethods.address[address] = { history: async (pageNumber = 0, pageSize = CHRONIK_DEFAULT_PAGESIZE) => { if (this.mockedResponses.address[address].history instanceof Error) { throw this.mockedResponses.address[address].history; } return this._getTxHistory(pageNumber, pageSize, this.mockedResponses.address[address].history); }, utxos: async () => { if (this.mockedResponses.address[address].utxos instanceof Error) { throw this.mockedResponses.address[address].utxos; } return this._getAddressUtxos(address, this.mockedResponses.address[address].utxos); }, }; } // Allow users to set expected chronik tokenId call responses _setTokenId(tokenId) { if (typeof this.mockedResponses.tokenId[tokenId] === 'undefined') { this.mockedResponses.tokenId[tokenId] = { history: [], utxos: [], }; } this.mockedMethods.tokenId[tokenId] = { history: async (pageNumber = 0, pageSize = CHRONIK_DEFAULT_PAGESIZE) => { if (this.mockedResponses.tokenId[tokenId].history instanceof Error) { throw this.mockedResponses.tokenId[tokenId].history; } return this._getTxHistory(pageNumber, pageSize, this.mockedResponses.tokenId[tokenId].history); }, utxos: async () => { if (this.mockedResponses.tokenId[tokenId].utxos instanceof Error) { throw this.mockedResponses.tokenId[tokenId].utxos; } return this._getTokenIdUtxos(tokenId, this.mockedResponses.tokenId[tokenId].utxos); }, }; } // Allow users to set expected chronik lokadId call responses _setLokadId(lokadId) { if (typeof this.mockedResponses.lokadId[lokadId] === 'undefined') { this.mockedResponses.lokadId[lokadId] = { history: [], utxos: [], }; } this.mockedMethods.lokadId[lokadId] = { history: async (pageNumber = 0, pageSize = CHRONIK_DEFAULT_PAGESIZE) => { if (this.mockedResponses.lokadId[lokadId].history instanceof Error) { throw this.mockedResponses.lokadId[lokadId].history; } return this._getTxHistory(pageNumber, pageSize, this.mockedResponses.lokadId[lokadId].history); }, }; } _setScript(type, hash) { if (typeof this.mockedResponses.script[type][hash] === 'undefined') { this.mockedResponses.script[type][hash] = { history: [], utxos: [], }; } this.mockedMethods.script[type][hash] = { history: async (pageNumber = 0, pageSize = CHRONIK_DEFAULT_PAGESIZE) => { if (this.mockedResponses.script[type][hash].history instanceof Error) { throw this.mockedResponses.script[type][hash].history; } return this._getTxHistory(pageNumber, pageSize, this.mockedResponses.script[type][hash].history); }, utxos: async () => { if (this.mockedResponses.script[type][hash].utxos instanceof Error) { throw this.mockedResponses.script[type][hash].utxos; } return this._getScriptUtxos(type, hash, this.mockedResponses.script[type][hash] .utxos); }, }; } _getScriptUtxos(type, hash, utxos) { const outputScript = (0, ecashaddrjs_1.getOutputScriptFromAddress)((0, ecashaddrjs_1.encodeCashAddress)('ecash', type, hash)); return { outputScript, utxos }; } _getAddressUtxos(address, utxos) { const outputScript = (0, ecashaddrjs_1.getOutputScriptFromAddress)(address); return { outputScript, utxos }; } _getTokenIdUtxos(tokenId, utxos) { return { tokenId, utxos }; } // Method to get paginated tx history with same variables as chronik _getTxHistory(pageNumber = 0, pageSize, history) { // Return chronik shaped responses const startSliceOnePage = pageNumber * pageSize; const endSliceOnePage = startSliceOnePage + pageSize; const thisPage = history.slice(startSliceOnePage, endSliceOnePage); const response = { txs: [], numPages: 0, numTxs: 0, }; response.txs = thisPage; response.numPages = Math.ceil(history.length / pageSize); response.numTxs = history.length; return response; } // Checks whether the user set this mock response to be an error. // If so, throw it to simulate an API error response. _throwOrReturnValue(mockResponse) { if (mockResponse instanceof Error) { throw mockResponse; } return mockResponse; } } exports.MockChronikClient = MockChronikClient; /** * Mock WsEndpoint for MockChronikClient. * Based on WsEndpoint in chronik-client. * We do not test network functionality * Useful for testing that methods are called as expected, * ws is opened and closed as expected, subscriptions are added * and removed as expected * * See Cashtab and token-server tests for implemented examples */ class MockWsEndpoint { constructor(config) { this.onMessage = config.onMessage; this.onConnect = config.onConnect; this.onReconnect = config.onReconnect; this.onEnd = config.onEnd; this.autoReconnect = config.autoReconnect !== undefined ? config.autoReconnect : true; this.manuallyClosed = false; this.subs = { scripts: [], tokens: [], lokadIds: [], plugins: [], blocks: false, }; this.waitForOpenCalled = false; } /** Wait for the WebSocket to be connected. */ async waitForOpen() { // We just set a flag that tests can use this.waitForOpenCalled = true; await this.connected; } /** * Subscribe to block messages */ subscribeToBlocks() { this.subs.blocks = true; this._subUnsubBlocks(false); } /** * Unsubscribe from block messages */ unsubscribeFromBlocks() { this.subs.blocks = false; this._subUnsubBlocks(true); } /** * Subscribe to the given script type and payload. * For "p2pkh", `scriptPayload` is the 20 byte public key hash. */ subscribeToScript(type, payload) { // Build sub according to chronik expected type const subscription = { scriptType: type, payload, }; this.subs.scripts.push(subscription); } /** Unsubscribe from the given script type and payload. */ unsubscribeFromScript(type, payload) { // Find the requested unsub script and remove it const unsubIndex = this.subs.scripts.findIndex(sub => sub.scriptType === type && sub.payload === payload); if (unsubIndex === -1) { // If we cannot find this subscription in this.subs, throw an error // We do not want an app developer thinking they have unsubscribed from something throw new Error(`No existing sub at ${type}, ${payload}`); } // Remove the requested subscription from this.subs this.subs.scripts.splice(unsubIndex, 1); } /** * Subscribe to an address * Method can be used for p2pkh or p2sh addresses */ subscribeToAddress(address) { // Get type and hash const { type, hash } = (0, ecashaddrjs_1.decodeCashAddress)(address); // Subscribe to script this.subscribeToScript(type, hash); } /** Unsubscribe from the given address */ unsubscribeFromAddress(address) { // Get type and hash const { type, hash } = (0, ecashaddrjs_1.decodeCashAddress)(address); // Unsubscribe from script this.unsubscribeFromScript(type, hash); } /** Subscribe to a lokadId */ subscribeToLokadId(lokadId) { // Update ws.subs to include this lokadId this.subs.lokadIds.push(lokadId); } /** Unsubscribe from the given lokadId */ unsubscribeFromLokadId(lokadId) { // Find the requested unsub lokadId and remove it const unsubIndex = this.subs.lokadIds.findIndex(thisLokadId => thisLokadId === lokadId); if (unsubIndex === -1) { // If we cannot find this subscription in this.subs.lokadIds, throw an error // We do not want an app developer thinking they have unsubscribed from something if no action happened throw new Error(`No existing sub at lokadId "${lokadId}"`); } // Remove the requested lokadId subscription from this.subs.lokadIds this.subs.lokadIds.splice(unsubIndex, 1); } /** Subscribe to a tokenId */ subscribeToTokenId(tokenId) { // Update ws.subs to include this tokenId this.subs.tokens.push(tokenId); } /** Unsubscribe from the given tokenId */ unsubscribeFromTokenId(tokenId) { // Find the requested unsub tokenId and remove it const unsubIndex = this.subs.tokens.findIndex(thisTokenId => thisTokenId === tokenId); if (unsubIndex === -1) { // If we cannot find this subscription in this.subs.tokens, throw an error // We do not want an app developer thinking they have unsubscribed from something if no action happened throw new Error(`No existing sub at tokenId "${tokenId}"`); } // Remove the requested tokenId subscription from this.subs.tokens this.subs.tokens.splice(unsubIndex, 1); } /** Subscribe to a plugin */ subscribeToPlugin(pluginName, group) { // Build sub according to chronik expected type const subscription = { pluginName, group, }; // Update ws.subs to include this plugin this.subs.plugins.push(subscription); } /** Unsubscribe from the given plugin */ unsubscribeFromPlugin(pluginName, group) { // Find the requested unsub script and remove it const unsubIndex = this.subs.plugins.findIndex(sub => sub.pluginName === pluginName && sub.group === group); if (unsubIndex === -1) { // If we cannot find this subscription in this.subs.plugins, throw an error // We do not want an app developer thinking they have unsubscribed from something throw new Error(`No existing sub at pluginName="${pluginName}", group="${group}"`); } // Remove the requested subscription from this.subs.plugins this.subs.plugins.splice(unsubIndex, 1); } /** * Close the WebSocket connection and prevent any future reconnection * attempts. */ close() { this.manuallyClosed = true; this.ws?.close(); } _subUnsubBlocks(isUnsub) { // Blocks subscription is empty object this.subs.blocks = !isUnsub; } } exports.MockWsEndpoint = MockWsEndpoint; //# sourceMappingURL=index.js.map