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
JavaScript
"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